Browse Source

Merge pull request #279 from maziggy/0.1.8b

v0.1.8b
MartinNYHC 3 months ago
parent
commit
996f78870f
100 changed files with 6163 additions and 972 deletions
  1. 2 0
      .github/workflows/security.yml
  2. 1 0
      .python-bin/python
  3. 105 1
      CHANGELOG.md
  4. 2 1
      Dockerfile
  5. 2 1
      Dockerfile.test
  6. 21 2
      README.md
  7. 131 26
      backend/app/api/routes/archives.py
  8. 129 16
      backend/app/api/routes/library.py
  9. 2 4
      backend/app/api/routes/metrics.py
  10. 3 3
      backend/app/api/routes/pending_uploads.py
  11. 66 1
      backend/app/api/routes/print_queue.py
  12. 318 1
      backend/app/api/routes/printers.py
  13. 8 0
      backend/app/api/routes/projects.py
  14. 25 1
      backend/app/api/routes/settings.py
  15. 213 97
      backend/app/core/auth.py
  16. 1 1
      backend/app/core/config.py
  17. 122 11
      backend/app/main.py
  18. 7 0
      backend/app/schemas/library.py
  19. 1 1
      backend/app/services/archive.py
  20. 172 33
      backend/app/services/bambu_ftp.py
  21. 13 6
      backend/app/services/bambu_mqtt.py
  22. 62 44
      backend/app/services/camera.py
  23. 12 12
      backend/app/services/github_backup.py
  24. 119 0
      backend/app/services/network_utils.py
  25. 50 11
      backend/app/services/notification_service.py
  26. 120 28
      backend/app/services/print_scheduler.py
  27. 2 2
      backend/app/services/spoolman.py
  28. 4 4
      backend/app/services/virtual_printer/certificate.py
  29. 118 21
      backend/app/services/virtual_printer/manager.py
  30. 245 20
      backend/app/services/virtual_printer/ssdp_server.py
  31. 3 2
      backend/app/services/virtual_printer/tcp_proxy.py
  32. 8 8
      backend/tests/unit/services/test_archive_service.py
  33. 163 2
      backend/tests/unit/services/test_virtual_printer.py
  34. 203 0
      backend/tests/unit/test_plate_object_extraction.py
  35. 202 0
      backend/tests/unit/test_scheduler_ams_mapping.py
  36. 1 1
      docker-compose.test.yml
  37. 3 0
      docker-compose.yml
  38. 7 2
      frontend/public/sw.js
  39. 25 2
      frontend/src/__tests__/api/client.test.ts
  40. 503 0
      frontend/src/__tests__/components/ModelViewerModal.test.tsx
  41. 2 1
      frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx
  42. 349 0
      frontend/src/__tests__/hooks/useFilamentMapping.test.ts
  43. 8 2
      frontend/src/__tests__/mocks/handlers.ts
  44. 8 2
      frontend/src/__tests__/pages/ArchivesPage.test.tsx
  45. 144 57
      frontend/src/__tests__/pages/FileManagerPage.test.tsx
  46. 67 51
      frontend/src/api/client.ts
  47. 4 2
      frontend/src/components/FilamentHoverCard.tsx
  48. 2 2
      frontend/src/components/FilamentTrends.tsx
  49. 236 3
      frontend/src/components/FileManagerModal.tsx
  50. 8 1
      frontend/src/components/GcodeViewer.tsx
  51. 6 1
      frontend/src/components/Layout.tsx
  52. 437 144
      frontend/src/components/ModelViewer.tsx
  53. 475 25
      frontend/src/components/ModelViewerModal.tsx
  54. 11 2
      frontend/src/components/PrintModal/index.tsx
  55. 58 4
      frontend/src/components/VirtualPrinterSettings.tsx
  56. 136 45
      frontend/src/hooks/useFilamentMapping.ts
  57. 51 7
      frontend/src/i18n/locales/de.ts
  58. 51 7
      frontend/src/i18n/locales/en.ts
  59. 63 0
      frontend/src/i18n/locales/ja.ts
  60. 13 2
      frontend/src/pages/ArchivesPage.tsx
  61. 183 54
      frontend/src/pages/FileManagerPage.tsx
  62. 1 1
      frontend/src/pages/PrintersPage.tsx
  63. 47 3
      frontend/src/pages/QueuePage.tsx
  64. 2 2
      frontend/src/pages/StatsPage.tsx
  65. 42 0
      frontend/src/types/plates.ts
  66. 45 18
      frontend/src/utils/date.ts
  67. 0 7
      frontend/vitest.config.ts
  68. 520 0
      install/start_bambuddy.bat
  69. BIN
      mockup/icons/ams-ht.png
  70. BIN
      mockup/icons/ams-ht.xcf
  71. 0 1
      mockup/icons/ams-settings.svg
  72. BIN
      mockup/icons/ams.png
  73. BIN
      mockup/icons/ams.xcf
  74. 0 0
      mockup/icons/chamber.svg
  75. BIN
      mockup/icons/dual-extruder.png
  76. BIN
      mockup/icons/dual-extruder.xcf
  77. BIN
      mockup/icons/extruder-left-right.png
  78. BIN
      mockup/icons/extruder-left-right.xcf
  79. 0 51
      mockup/icons/eye.svg
  80. 0 1
      mockup/icons/heatbed.svg
  81. 0 44
      mockup/icons/home.svg
  82. 0 1
      mockup/icons/hotend.svg
  83. BIN
      mockup/icons/jogpad.png
  84. 0 5
      mockup/icons/jogpad.svg
  85. BIN
      mockup/icons/jogpad.xcf
  86. 0 1
      mockup/icons/lamp.svg
  87. 0 1
      mockup/icons/micro-sd.svg
  88. 0 1
      mockup/icons/reload.svg
  89. 0 0
      mockup/icons/settings.svg
  90. BIN
      mockup/icons/single-extruder1.png
  91. BIN
      mockup/icons/single-extruder1.xcf
  92. BIN
      mockup/icons/single-extruder2.png
  93. BIN
      mockup/icons/single-extruder2.xcf
  94. 0 1
      mockup/icons/skip-objects.svg
  95. 0 53
      mockup/icons/snowflake.svg
  96. 0 0
      mockup/icons/speed.svg
  97. 0 1
      mockup/icons/temperature.svg
  98. 0 0
      mockup/icons/ventilation.svg
  99. 0 1
      mockup/icons/video-camera.svg
  100. 0 2
      mockup/icons/water.svg

+ 2 - 0
.github/workflows/security.yml

@@ -79,6 +79,7 @@ jobs:
           format: 'sarif'
           output: 'trivy-results.sarif'
           severity: 'CRITICAL,HIGH,MEDIUM'
+          version: 'v0.69.1'
 
       - name: Upload Trivy results to GitHub Security
         uses: github/codeql-action/upload-sarif@v4
@@ -95,6 +96,7 @@ jobs:
           format: 'sarif'
           output: 'trivy-config-results.sarif'
           severity: 'CRITICAL,HIGH,MEDIUM'
+          version: 'v0.69.1'
 
       - name: Upload Trivy config results
         uses: github/codeql-action/upload-sarif@v4

+ 1 - 0
.python-bin/python

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

+ 105 - 1
CHANGELOG.md

@@ -2,7 +2,111 @@
 
 All notable changes to Bambuddy will be documented in this file.
 
-## [0.1.7b] - Not released
+
+## [0.1.8] - Not released
+
+### Security
+- **XML External Entity (XXE) Prevention**:
+  - Replaced `xml.etree.ElementTree` with `defusedxml` across all 3MF parsing code
+  - Prevents XXE attacks through malicious 3MF files
+  - Detected by Bandit B314 security scanner
+- **Path Injection Vulnerabilities Fixed**:
+  - Added path traversal validation to project attachment endpoints
+  - Strengthened filename sanitization in timelapse processing
+  - Prevents directory traversal attacks via `../` sequences
+  - Detected by CodeQL security scanner
+- **Security Scanning in CI/CD**:
+  - Added Bandit (Python security analyzer) with SARIF upload to GitHub Security
+  - Added Trivy (container/IaC scanner) for Docker image and Dockerfile analysis
+  - Added pip-audit and npm-audit for dependency vulnerability scanning
+  - Automatic GitHub issue creation for detected vulnerabilities
+  - Security scan results visible in GitHub Security tab
+
+### Enhanced
+- **3D Model Viewer Improvements** (PR #262):
+  - Added plate selector for multi-plate 3MF files with thumbnail previews
+  - Object count display shows number of objects per plate and total
+  - Fullscreen toggle for immersive model viewing
+  - Resizable split view between plate selector and 3D viewer in fullscreen mode
+  - Pagination support for files with many plates (e.g., 50+ plates)
+  - Added i18n translations for all model viewer strings (English, German, Japanese)
+- **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
+- **A1/A1 Mini FTP Upload Failures** (Issue #271):
+  - Fixed FTP uploads hanging/timing out on A1 and A1 Mini printers
+  - Replaced `storbinary()` with manual chunked transfer using `transfercmd()`
+  - A1's FTP server has issues with Python's `storbinary()` waiting for completion response
+  - Uses 1MB chunks with explicit 120s socket timeout for reliable transfers
+  - Works for all printer models (X1C, P1S, P1P, A1, A1 Mini)
+- **P1S/P1P FTP Upload Failures**:
+  - Fixed FTP uploads failing with EOFError on P1S and P1P printers
+  - These printers use vsFTPd which requires SSL session reuse on data channel
+  - Removed P1S/P1P from skip-session-reuse list (they were incorrectly added)
+- **FTP Auto-Detection for A1 Printers**:
+  - Automatically detects working FTP mode (prot_p vs prot_c) for A1/A1 Mini
+  - Tries encrypted data channel first, falls back to clear if needed
+  - Caches working mode per printer IP to avoid repeated detection
+- **Safari Camera Stream Failing**:
+  - Fixed camera streams not loading in Safari due to Service Worker error
+  - Safari has stricter Service Worker scope requirements
+- **Queue Print Time for Multi-Plate Files** (PR #274):
+  - Fixed print time showing total for all plates instead of selected plate
+  - Now extracts per-plate print time from 3MF slice_info.config
+  - Contributed by MisterBeardy
+- **Docker Permissions**:
+  - Added user directive to docker-compose.yml using PUID/PGID environment variables
+  - Allows container to run as host user, fixing permission issues with bind-mounted volumes
+  - Usage: `PUID=$(id -u) PGID=$(id -g) docker compose up -d`
+
+### Added
+- **Windows Portable Launcher** (contributed by nmori):
+  - New `start_bambuddy.bat` for Windows users - double-click to run, no installation required
+  - Automatically downloads Python 3.13 and Node.js 22 on first run (portable, no system changes)
+  - Everything stored in `.portable\` folder for easy cleanup
+  - Commands: `start_bambuddy.bat` (launch), `start_bambuddy.bat update` (update deps), `start_bambuddy.bat reset` (clean start)
+  - Custom port via `set PORT=9000 & start_bambuddy.bat`
+  - Verifies all downloads with SHA256 checksums for security
+  - Supports both x64 and ARM64 Windows systems
+
+## [0.1.7] - 2026-02-03
 
 ### Security
 - **Critical: Missing API Endpoint Authentication** (CVE-2026-25505, CVSS 9.8):

+ 2 - 1
Dockerfile

@@ -37,7 +37,8 @@ COPY backend/ ./backend/
 COPY --from=frontend-builder /app/static ./static
 
 # Create data directory for persistent storage
-RUN mkdir -p /app/data /app/logs
+# chmod 777 allows running as non-root user (e.g., with docker compose user: directive)
+RUN mkdir -p /app/data /app/logs && chmod 777 /app/data /app/logs
 
 # Environment variables
 ENV PYTHONUNBUFFERED=1

+ 2 - 1
Dockerfile.test

@@ -26,7 +26,8 @@ ENV DATA_DIR=/app/data
 ENV TESTING=1
 
 # Default command runs pytest (excluding docker integration tests)
-CMD ["pytest", "backend/tests/", "-v", "--tb=short", "-p", "no:cacheprovider"]
+# Use -n auto for parallel execution (auto-detects available CPUs)
+CMD ["pytest", "backend/tests/", "-v", "--tb=short", "-p", "no:cacheprovider", "-n", "auto"]
 
 # -------------------------------------------
 # Frontend test stage

+ 21 - 2
README.md

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

+ 131 - 26
backend/app/api/routes/archives.py

@@ -466,10 +466,8 @@ async def get_archive_stats(
             total_seconds += print_time_seconds
     total_time = total_seconds / 3600  # Convert to hours
 
-    # Multiply filament by quantity to account for multiple items printed
-    filament_result = await db.execute(
-        select(func.sum(PrintArchive.filament_used_grams * func.coalesce(PrintArchive.quantity, 1)))
-    )
+    # Sum filament directly - filament_used_grams already contains the total for the print job
+    filament_result = await db.execute(select(func.coalesce(func.sum(PrintArchive.filament_used_grams), 0)))
     total_filament = filament_result.scalar() or 0
 
     cost_result = await db.execute(select(func.sum(PrintArchive.cost)))
@@ -1592,7 +1590,10 @@ async def process_timelapse(
             raise HTTPException(400, "Audio must be .mp3, .wav, .m4a, .aac, or .ogg")
 
         audio_content = await audio.read()
-        suffix = Path(audio.filename).suffix
+        # Extract and validate suffix to prevent path injection
+        suffix = Path(audio.filename).suffix.lower()
+        if suffix not in (".mp3", ".wav", ".m4a", ".aac", ".ogg"):
+            raise HTTPException(400, "Invalid audio file extension")
         audio_temp_path = Path(tempfile.gettempdir()) / f"audio_{archive_id}{suffix}"
         audio_temp_path.write_bytes(audio_content)
 
@@ -1607,8 +1608,11 @@ async def process_timelapse(
         else:
             # Save as new file alongside original
             filename = output_filename or f"{archive.print_name or 'timelapse'}_edited.mp4"
-            # Sanitize filename
+            # Sanitize filename - remove path separators and traversal sequences
             filename = "".join(c for c in filename if c.isalnum() or c in "._- ")
+            # Prevent path traversal
+            if ".." in filename or not filename or filename.startswith("."):
+                filename = f"timelapse_{archive_id}_edited"
             if not filename.endswith(".mp4"):
                 filename += ".mp4"
             output_path = archive_dir / filename
@@ -1835,7 +1839,8 @@ async def get_archive_capabilities(
 ):
     """Check what viewing capabilities are available for this 3MF file."""
     import json
-    import xml.etree.ElementTree as ET
+
+    import defusedxml.ElementTree as ET
 
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
@@ -2115,7 +2120,7 @@ async def get_plate_preview(
             plate_num = 1
             if "Metadata/slice_info.config" in names:
                 try:
-                    import xml.etree.ElementTree as ET
+                    import defusedxml.ElementTree as ET
 
                     slice_content = zf.read("Metadata/slice_info.config").decode("utf-8")
                     root = ET.fromstring(slice_content)
@@ -2254,7 +2259,10 @@ async def get_archive_plates(
     Returns a list of plates with their index, name, thumbnail availability,
     and filament requirements. For single-plate exports, returns a single plate.
     """
-    import xml.etree.ElementTree as ET
+    import json
+    import re
+
+    import defusedxml.ElementTree as ET
 
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
@@ -2274,29 +2282,72 @@ async def get_archive_plates(
             # Find all plate gcode files to determine available plates
             gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
 
-            if not gcode_files:
-                # No sliced plates found
-                return {"archive_id": archive_id, "filename": archive.filename, "plates": []}
-
-            # 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()
 
-            # Parse model_settings.config for plate names
+            # Parse model_settings.config for plate names + object assignments
             # Plate names are stored with plater_id and plater_name keys
             plate_names = {}  # plater_id -> name
+            plate_object_ids: dict[int, list[str]] = {}
+            object_names_by_id: dict[str, str] = {}
             if "Metadata/model_settings.config" in namelist:
                 try:
                     model_content = zf.read("Metadata/model_settings.config").decode()
                     model_root = ET.fromstring(model_content)
+                    # Build object ID -> name map
+                    for obj_elem in model_root.findall(".//object"):
+                        obj_id = obj_elem.get("id")
+                        if not obj_id:
+                            continue
+                        name_meta = obj_elem.find("metadata[@key='name']")
+                        obj_name = name_meta.get("value") if name_meta is not None else None
+                        if obj_name:
+                            object_names_by_id[obj_id] = obj_name
+
                     for plate_elem in model_root.findall(".//plate"):
                         plater_id = None
                         plater_name = None
@@ -2312,6 +2363,17 @@ async def get_archive_plates(
                                 plater_name = value.strip()
                         if plater_id is not None and plater_name:
                             plate_names[plater_id] = plater_name
+
+                        if plater_id is not None:
+                            for instance_elem in plate_elem.findall("model_instance"):
+                                for inst_meta in instance_elem.findall("metadata"):
+                                    if inst_meta.get("key") == "object_id":
+                                        obj_id = inst_meta.get("value")
+                                        if not obj_id:
+                                            continue
+                                        plate_object_ids.setdefault(plater_id, [])
+                                        if obj_id not in plate_object_ids[plater_id]:
+                                            plate_object_ids[plater_id].append(obj_id)
                 except Exception:
                     pass  # model_settings.config parsing is optional
 
@@ -2391,16 +2453,53 @@ async def get_archive_plates(
 
                         plate_metadata[plate_index] = plate_info
 
+            # Parse plate_*.json for object lists when slice_info is missing
+            plate_json_objects: dict[int, list[str]] = {}
+            for name in namelist:
+                match = re.match(r"^Metadata/plate_(\d+)\.json$", name)
+                if not match:
+                    continue
+                try:
+                    plate_index = int(match.group(1))
+                except ValueError:
+                    continue
+                try:
+                    payload = json.loads(zf.read(name).decode())
+                    bbox_objects = payload.get("bbox_objects", [])
+                    names = []
+                    for obj in bbox_objects:
+                        obj_name = obj.get("name") if isinstance(obj, dict) else None
+                        if obj_name and obj_name not in names:
+                            names.append(obj_name)
+                    if names:
+                        plate_json_objects[plate_index] = names
+                except Exception:
+                    continue
+
             # Build plate list
             for idx in plate_indices:
                 meta = plate_metadata.get(idx, {})
                 has_thumbnail = f"Metadata/plate_{idx}.png" in namelist
+                objects = meta.get("objects", [])
+                if not objects:
+                    objects = plate_json_objects.get(idx, [])
+                if not objects and plate_object_ids.get(idx):
+                    objects = [
+                        object_names_by_id.get(obj_id, f"Object {obj_id}") for obj_id in plate_object_ids.get(idx, [])
+                    ]
+
+                plate_name = meta.get("name")
+                if not plate_name:
+                    plate_name = plate_names.get(idx)
+                if not plate_name and objects:
+                    plate_name = objects[0]
 
                 plates.append(
                     {
                         "index": idx,
-                        "name": meta.get("name"),
-                        "objects": meta.get("objects", []),
+                        "name": plate_name,
+                        "objects": objects,
+                        "object_count": len(objects),
                         "has_thumbnail": has_thumbnail,
                         "thumbnail_url": f"/api/v1/archives/{archive_id}/plate-thumbnail/{idx}"
                         if has_thumbnail
@@ -2469,7 +2568,7 @@ async def get_filament_requirements(
         archive_id: The archive ID
         plate_id: Optional plate index to filter filaments for (for multi-plate files)
     """
-    import xml.etree.ElementTree as ET
+    import defusedxml.ElementTree as ET
 
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
@@ -2511,6 +2610,8 @@ async def get_filament_requirements(
                                 used_g = filament_elem.get("used_g", "0")
                                 used_m = filament_elem.get("used_m", "0")
 
+                                tray_info_idx = filament_elem.get("tray_info_idx", "")
+
                                 try:
                                     used_grams = float(used_g)
                                 except (ValueError, TypeError):
@@ -2524,6 +2625,7 @@ async def get_filament_requirements(
                                             "color": filament_color,
                                             "used_grams": round(used_grams, 1),
                                             "used_meters": float(used_m) if used_m else 0,
+                                            "tray_info_idx": tray_info_idx,
                                         }
                                     )
                             break
@@ -2537,6 +2639,8 @@ async def get_filament_requirements(
                         used_g = filament_elem.get("used_g", "0")
                         used_m = filament_elem.get("used_m", "0")
 
+                        tray_info_idx = filament_elem.get("tray_info_idx", "")
+
                         # Only include filaments that are actually used
                         try:
                             used_grams = float(used_g)
@@ -2551,6 +2655,7 @@ async def get_filament_requirements(
                                     "color": filament_color,
                                     "used_grams": round(used_grams, 1),
                                     "used_meters": float(used_m) if used_m else 0,
+                                    "tray_info_idx": tray_info_idx,
                                 }
                             )
 

+ 129 - 16
backend/app/api/routes/library.py

@@ -664,10 +664,12 @@ async def list_files(
         print_name = None
         print_time = None
         filament_grams = None
+        sliced_for_model = None
         if f.file_metadata:
             print_name = f.file_metadata.get("print_name")
             print_time = f.file_metadata.get("print_time_seconds")
             filament_grams = f.file_metadata.get("filament_used_grams")
+            sliced_for_model = f.file_metadata.get("sliced_for_model")
 
         file_list.append(
             FileListResponse(
@@ -685,6 +687,7 @@ async def list_files(
                 print_name=print_name,
                 print_time_seconds=print_time,
                 filament_used_grams=filament_grams,
+                sliced_for_model=sliced_for_model,
             )
         )
 
@@ -1307,9 +1310,12 @@ async def get_library_file_plates(
     Returns a list of plates with their index, name, thumbnail availability,
     and filament requirements. For single-plate exports, returns a single plate.
     """
-    import xml.etree.ElementTree as ET
+    import json
+    import re
     import zipfile
 
+    import defusedxml.ElementTree as ET
+
     # Get the library file
     result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
     lib_file = result.scalar_one_or_none()
@@ -1334,27 +1340,64 @@ async def get_library_file_plates(
             # Find all plate gcode files to determine available plates
             gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
 
-            if not gcode_files:
-                # No sliced plates found
+            # 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}
 
-            # 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()
 
-            # Parse model_settings.config for plate names
+            # Parse model_settings.config for plate names + object assignments
             plate_names = {}
+            plate_object_ids: dict[int, list[str]] = {}
+            object_names_by_id: dict[str, str] = {}
             if "Metadata/model_settings.config" in namelist:
                 try:
                     model_content = zf.read("Metadata/model_settings.config").decode()
                     model_root = ET.fromstring(model_content)
+                    for obj_elem in model_root.findall(".//object"):
+                        obj_id = obj_elem.get("id")
+                        if not obj_id:
+                            continue
+                        name_meta = obj_elem.find("metadata[@key='name']")
+                        obj_name = name_meta.get("value") if name_meta is not None else None
+                        if obj_name:
+                            object_names_by_id[obj_id] = obj_name
+
                     for plate_elem in model_root.findall(".//plate"):
                         plater_id = None
                         plater_name = None
@@ -1370,6 +1413,17 @@ async def get_library_file_plates(
                                 plater_name = value.strip()
                         if plater_id is not None and plater_name:
                             plate_names[plater_id] = plater_name
+
+                        if plater_id is not None:
+                            for instance_elem in plate_elem.findall("model_instance"):
+                                for inst_meta in instance_elem.findall("metadata"):
+                                    if inst_meta.get("key") == "object_id":
+                                        obj_id = inst_meta.get("value")
+                                        if not obj_id:
+                                            continue
+                                        plate_object_ids.setdefault(plater_id, [])
+                                        if obj_id not in plate_object_ids[plater_id]:
+                                            plate_object_ids[plater_id].append(obj_id)
                 except Exception:
                     pass
 
@@ -1443,16 +1497,53 @@ async def get_library_file_plates(
                             plate_info["name"] = plate_info["objects"][0]
                         plate_metadata[plate_index] = plate_info
 
+            # Parse plate_*.json for object lists when slice_info is missing
+            plate_json_objects: dict[int, list[str]] = {}
+            for name in namelist:
+                match = re.match(r"^Metadata/plate_(\d+)\.json$", name)
+                if not match:
+                    continue
+                try:
+                    plate_index = int(match.group(1))
+                except ValueError:
+                    continue
+                try:
+                    payload = json.loads(zf.read(name).decode())
+                    bbox_objects = payload.get("bbox_objects", [])
+                    names: list[str] = []
+                    for obj in bbox_objects:
+                        obj_name = obj.get("name") if isinstance(obj, dict) else None
+                        if obj_name and obj_name not in names:
+                            names.append(obj_name)
+                    if names:
+                        plate_json_objects[plate_index] = names
+                except Exception:
+                    continue
+
             # Build plate list
             for idx in plate_indices:
                 meta = plate_metadata.get(idx, {})
                 has_thumbnail = f"Metadata/plate_{idx}.png" in namelist
+                objects = meta.get("objects", [])
+                if not objects:
+                    objects = plate_json_objects.get(idx, [])
+                if not objects and plate_object_ids.get(idx):
+                    objects = [
+                        object_names_by_id.get(obj_id, f"Object {obj_id}") for obj_id in plate_object_ids.get(idx, [])
+                    ]
+
+                plate_name = meta.get("name")
+                if not plate_name:
+                    plate_name = plate_names.get(idx)
+                if not plate_name and objects:
+                    plate_name = objects[0]
 
                 plates.append(
                     {
                         "index": idx,
-                        "name": meta.get("name"),
-                        "objects": meta.get("objects", []),
+                        "name": plate_name,
+                        "objects": objects,
+                        "object_count": len(objects),
                         "has_thumbnail": has_thumbnail,
                         "thumbnail_url": f"/api/v1/library/files/{file_id}/plate-thumbnail/{idx}"
                         if has_thumbnail
@@ -1523,9 +1614,10 @@ async def get_library_file_filament_requirements(
         file_id: The library file ID
         plate_id: Optional plate index to get filaments for a specific plate
     """
-    import xml.etree.ElementTree as ET
     import zipfile
 
+    import defusedxml.ElementTree as ET
+
     # Get the library file
     result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
     lib_file = result.scalar_one_or_none()
@@ -1574,6 +1666,8 @@ async def get_library_file_filament_requirements(
                                 used_g = filament_elem.get("used_g", "0")
                                 used_m = filament_elem.get("used_m", "0")
 
+                                tray_info_idx = filament_elem.get("tray_info_idx", "")
+
                                 try:
                                     used_grams = float(used_g)
                                 except (ValueError, TypeError):
@@ -1587,6 +1681,7 @@ async def get_library_file_filament_requirements(
                                             "color": filament_color,
                                             "used_grams": round(used_grams, 1),
                                             "used_meters": float(used_m) if used_m else 0,
+                                            "tray_info_idx": tray_info_idx,
                                         }
                                     )
                             break
@@ -1599,6 +1694,8 @@ async def get_library_file_filament_requirements(
                         used_g = filament_elem.get("used_g", "0")
                         used_m = filament_elem.get("used_m", "0")
 
+                        tray_info_idx = filament_elem.get("tray_info_idx", "")
+
                         try:
                             used_grams = float(used_g)
                         except (ValueError, TypeError):
@@ -1612,6 +1709,7 @@ async def get_library_file_filament_requirements(
                                     "color": filament_color,
                                     "used_grams": round(used_grams, 1),
                                     "used_meters": float(used_m) if used_m else 0,
+                                    "tray_info_idx": tray_info_idx,
                                 }
                             )
 
@@ -1870,6 +1968,17 @@ async def get_file(
             )
         duplicate_count = len(duplicates)
 
+    # Extract key metadata fields
+    print_name = None
+    print_time = None
+    filament_grams = None
+    sliced_for_model = None
+    if file.file_metadata:
+        print_name = file.file_metadata.get("print_name")
+        print_time = file.file_metadata.get("print_time_seconds")
+        filament_grams = file.file_metadata.get("filament_used_grams")
+        sliced_for_model = file.file_metadata.get("sliced_for_model")
+
     return FileResponseSchema(
         id=file.id,
         folder_id=file.folder_id,
@@ -1892,6 +2001,10 @@ async def get_file(
         created_by_username=file.created_by.username if file.created_by else None,
         created_at=file.created_at,
         updated_at=file.updated_at,
+        print_name=print_name,
+        print_time_seconds=print_time,
+        filament_used_grams=filament_grams,
+        sliced_for_model=sliced_for_model,
     )
 
 

+ 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}")
 
-    # Total filament used (multiply by quantity to account for multiple items printed)
+    # Total filament used - filament_used_grams already contains the total for each print job
     lines.append("")
     lines.append("# HELP bambuddy_filament_used_grams Total filament used in grams")
     lines.append("# TYPE bambuddy_filament_used_grams counter")
-    result = await db.execute(
-        select(func.coalesce(func.sum(PrintArchive.filament_used_grams * func.coalesce(PrintArchive.quantity, 1)), 0))
-    )
+    result = await db.execute(select(func.coalesce(func.sum(PrintArchive.filament_used_grams), 0)))
     total_filament = result.scalar() or 0
     lines.append(f"bambuddy_filament_used_grams {total_filament:.1f}")
 

+ 3 - 3
backend/app/api/routes/pending_uploads.py

@@ -1,6 +1,6 @@
 """API routes for pending uploads (virtual printer queue mode)."""
 
-from datetime import UTC, datetime
+from datetime import datetime, timezone
 from pathlib import Path
 
 from fastapi import APIRouter, Depends, HTTPException
@@ -107,7 +107,7 @@ async def archive_all_pending(
             if archive:
                 pending.status = "archived"
                 pending.archived_id = archive.id
-                pending.archived_at = datetime.now(UTC)
+                pending.archived_at = datetime.now(timezone.utc)
                 archived += 1
 
                 # Clean up temp file
@@ -219,7 +219,7 @@ async def archive_pending_upload(
     # Update pending record
     pending.status = "archived"
     pending.archived_id = archive.id
-    pending.archived_at = datetime.now(UTC)
+    pending.archived_at = datetime.now(timezone.utc)
     if request:
         pending.tags = request.tags
         pending.notes = request.notes

+ 66 - 1
backend/app/api/routes/print_queue.py

@@ -2,11 +2,11 @@
 
 import json
 import logging
-import xml.etree.ElementTree as ET
 import zipfile
 from datetime import datetime
 from pathlib import Path
 
+import defusedxml.ElementTree as ET
 from fastapi import APIRouter, Depends, HTTPException, Query
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
@@ -98,6 +98,58 @@ def _extract_filament_types_from_3mf(file_path: Path, plate_id: int | None = Non
     return sorted(types)
 
 
+def _extract_print_time_from_3mf(file_path: Path, plate_id: int | None = None) -> int | None:
+    """Extract print time (prediction) from a 3MF file.
+
+    Args:
+        file_path: Path to the 3MF file
+        plate_id: Optional plate index to filter for (for multi-plate files)
+
+    Returns:
+        Print time in seconds, or None if not found
+    """
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            if "Metadata/slice_info.config" not in zf.namelist():
+                return None
+
+            content = zf.read("Metadata/slice_info.config").decode()
+            root = ET.fromstring(content)
+
+            if plate_id is not None:
+                for plate_elem in root.findall(".//plate"):
+                    plate_index = None
+                    for meta in plate_elem.findall("metadata"):
+                        if meta.get("key") == "index":
+                            try:
+                                plate_index = int(meta.get("value", "0"))
+                            except ValueError:
+                                pass
+                            break
+
+                    if plate_index == plate_id:
+                        for meta in plate_elem.findall("metadata"):
+                            if meta.get("key") == "prediction":
+                                try:
+                                    return int(meta.get("value", "0"))
+                                except ValueError:
+                                    return None
+                        break
+            else:
+                plate_elem = root.find(".//plate")
+                if plate_elem is not None:
+                    for meta in plate_elem.findall("metadata"):
+                        if meta.get("key") == "prediction":
+                            try:
+                                return int(meta.get("value", "0"))
+                            except ValueError:
+                                return None
+    except Exception as e:
+        logger.warning(f"Failed to extract print time from {file_path}: {e}")
+
+    return None
+
+
 def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
     """Add nested archive/printer/library_file info to response."""
     # Parse ams_mapping from JSON string BEFORE model_validate
@@ -153,6 +205,12 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         response.archive_name = item.archive.print_name or item.archive.filename
         response.archive_thumbnail = item.archive.thumbnail_path
         response.print_time_seconds = item.archive.print_time_seconds
+        if item.plate_id:
+            archive_path = settings.base_dir / item.archive.file_path
+            if archive_path.exists():
+                plate_time = _extract_print_time_from_3mf(archive_path, item.plate_id)
+                if plate_time is not None:
+                    response.print_time_seconds = plate_time
     if item.library_file:
         response.library_file_name = (
             item.library_file.file_metadata.get("print_name") if item.library_file.file_metadata else None
@@ -163,6 +221,13 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         # Get print time from library file metadata if no archive
         if not item.archive and item.library_file.file_metadata:
             response.print_time_seconds = item.library_file.file_metadata.get("print_time_seconds")
+        if item.plate_id:
+            lib_path = Path(item.library_file.file_path)
+            library_file_path = lib_path if lib_path.is_absolute() else settings.base_dir / item.library_file.file_path
+            if library_file_path.exists():
+                plate_time = _extract_print_time_from_3mf(library_file_path, item.plate_id)
+                if plate_time is not None:
+                    response.print_time_seconds = plate_time
     if item.printer:
         response.printer_name = item.printer.name
     return response

+ 318 - 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(
     printer_id: int,
     view: str | None = None,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
 ):
+    # Note: No auth required - this is an image asset loaded via <img src> which can't send auth headers
     """Get the cover image for the current print job.
 
     Args:
@@ -803,6 +803,323 @@ 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 zipfile
+
+    import defusedxml.ElementTree as ET
+
+    # 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,
+                        "object_count": len(objects),
+                        "has_thumbnail": has_thumbnail,
+                        "thumbnail_url": f"/api/v1/printers/{printer_id}/files/plate-thumbnail/{idx}?path={path}",
+                        "print_time_seconds": meta.get("prediction"),
+                        "filament_used_grams": meta.get("weight"),
+                        "filaments": meta.get("filaments", []),
+                    }
+                )
+
+    except Exception as e:
+        logger.warning(f"Failed to parse plates from printer file {path}: {e}")
+
+    return {
+        "printer_id": printer_id,
+        "path": path,
+        "filename": filename,
+        "plates": plates,
+        "is_multi_plate": len(plates) > 1,
+    }
+
+
+@router.get("/{printer_id}/files/plate-thumbnail/{plate_index}")
+async def get_printer_file_plate_thumbnail(
+    printer_id: int,
+    plate_index: int,
+    path: str = Query(..., description="Full path to the 3MF file on the printer"),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
+    db: AsyncSession = Depends(get_db),
+):
+    """Get a plate thumbnail image from a printer-stored 3MF file."""
+    import io
+    import zipfile
+
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    data = await download_file_bytes_async(printer.ip_address, printer.access_code, path)
+    if data is None:
+        raise HTTPException(404, f"File not found: {path}")
+
+    try:
+        with zipfile.ZipFile(io.BytesIO(data), "r") as zf:
+            thumb_path = f"Metadata/plate_{plate_index}.png"
+            if thumb_path in zf.namelist():
+                image_data = zf.read(thumb_path)
+                return Response(content=image_data, media_type="image/png")
+    except Exception:
+        pass
+
+    raise HTTPException(status_code=404, detail=f"Thumbnail for plate {plate_index} not found")
+
+
 @router.post("/{printer_id}/files/download-zip")
 async def download_printer_files_as_zip(
     printer_id: int,

+ 8 - 0
backend/app/api/routes/projects.py

@@ -907,6 +907,10 @@ async def download_attachment(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
 ):
     """Download an attachment from a project."""
+    # Validate filename to prevent path traversal
+    if "/" in filename or "\\" in filename or ".." in filename or not filename:
+        raise HTTPException(status_code=400, detail="Invalid filename")
+
     # Verify project exists
     result = await db.execute(select(Project).where(Project.id == project_id))
     project = result.scalar_one_or_none()
@@ -939,6 +943,10 @@ async def delete_attachment(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
 ):
     """Delete an attachment from a project."""
+    # Validate filename to prevent path traversal
+    if "/" in filename or "\\" in filename or ".." in filename or not filename:
+        raise HTTPException(status_code=400, detail="Invalid filename")
+
     # Verify project exists
     result = await db.execute(select(Project).where(Project.id == project_id))
     project = result.scalar_one_or_none()

+ 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")
 async def get_virtual_printer_models(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
@@ -466,6 +477,7 @@ async def get_virtual_printer_settings(
     mode = await get_setting(db, "virtual_printer_mode")
     model = await get_setting(db, "virtual_printer_model")
     target_printer_id = await get_setting(db, "virtual_printer_target_printer_id")
+    remote_interface_ip = await get_setting(db, "virtual_printer_remote_interface_ip")
 
     return {
         "enabled": enabled == "true" if enabled else False,
@@ -473,6 +485,7 @@ async def get_virtual_printer_settings(
         "mode": mode or "immediate",
         "model": model or DEFAULT_VIRTUAL_PRINTER_MODEL,
         "target_printer_id": int(target_printer_id) if target_printer_id else None,
+        "remote_interface_ip": remote_interface_ip or "",
         "status": virtual_printer_manager.get_status(),
     }
 
@@ -484,10 +497,16 @@ async def update_virtual_printer_settings(
     mode: str = None,
     model: str = None,
     target_printer_id: int = None,
+    remote_interface_ip: str = None,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
-    """Update virtual printer settings and restart services if needed."""
+    """Update virtual printer settings and restart services if needed.
+
+    For proxy mode with SSDP proxy (dual-homed setup):
+    - remote_interface_ip: IP of interface on slicer's network (LAN B)
+    - Local interface is auto-detected based on target printer IP
+    """
     from sqlalchemy import select
 
     from backend.app.models.printer import Printer
@@ -504,6 +523,7 @@ async def update_virtual_printer_settings(
     current_model = await get_setting(db, "virtual_printer_model") or DEFAULT_VIRTUAL_PRINTER_MODEL
     current_target_id_str = await get_setting(db, "virtual_printer_target_printer_id")
     current_target_id = int(current_target_id_str) if current_target_id_str else None
+    current_remote_iface = await get_setting(db, "virtual_printer_remote_interface_ip") or ""
 
     # Apply updates
     new_enabled = enabled if enabled is not None else current_enabled
@@ -511,6 +531,7 @@ async def update_virtual_printer_settings(
     new_mode = mode if mode is not None else current_mode
     new_model = model if model is not None else current_model
     new_target_id = target_printer_id if target_printer_id is not None else current_target_id
+    new_remote_iface = remote_interface_ip if remote_interface_ip is not None else current_remote_iface
 
     # Validate mode
     # "review" is the new name for "queue" (pending review before archiving)
@@ -587,6 +608,8 @@ async def update_virtual_printer_settings(
         await set_setting(db, "virtual_printer_model", model)
     if target_printer_id is not None:
         await set_setting(db, "virtual_printer_target_printer_id", str(target_printer_id))
+    if remote_interface_ip is not None:
+        await set_setting(db, "virtual_printer_remote_interface_ip", remote_interface_ip)
     await db.commit()
     db.expire_all()
 
@@ -599,6 +622,7 @@ async def update_virtual_printer_settings(
             model=new_model,
             target_printer_ip=target_printer_ip,
             target_printer_serial=target_printer_serial,
+            remote_interface_ip=new_remote_iface,
         )
     except ValueError as e:
         logger.warning(f"Virtual printer configuration validation error: {e}")

+ 213 - 97
backend/app/core/auth.py

@@ -158,6 +158,29 @@ async def is_auth_enabled(db: AsyncSession) -> bool:
         return False
 
 
+async def _validate_api_key(db: AsyncSession, api_key_value: str) -> APIKey | None:
+    """Validate an API key and return the APIKey object if valid, None otherwise.
+
+    This is an internal helper used by auth functions to check API keys.
+    """
+    try:
+        result = await db.execute(select(APIKey).where(APIKey.enabled.is_(True)))
+        api_keys = result.scalars().all()
+
+        for api_key in api_keys:
+            if verify_password(api_key_value, api_key.key_hash):
+                # Check expiration
+                if api_key.expires_at and api_key.expires_at < datetime.now():
+                    return None  # Expired
+                # Update last_used timestamp
+                api_key.last_used = datetime.now()
+                await db.commit()
+                return api_key
+    except Exception as e:
+        logger.warning(f"API key validation error: {e}")
+    return None
+
+
 async def get_current_user_optional(
     credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
 ) -> User | None:
@@ -220,45 +243,70 @@ async def get_current_active_user(current_user: Annotated[User, Depends(get_curr
 
 async def require_auth_if_enabled(
     credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+    x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
 ) -> User | None:
-    """Require authentication if auth is enabled, otherwise return None."""
+    """Require authentication if auth is enabled, otherwise return None.
+
+    Accepts both JWT tokens (via Authorization: Bearer header) and API keys
+    (via X-API-Key header or Authorization: Bearer bb_xxx).
+    """
     async with async_session() as db:
         auth_enabled = await is_auth_enabled(db)
         if not auth_enabled:
             return None
 
-        if credentials is None:
-            raise HTTPException(
-                status_code=status.HTTP_401_UNAUTHORIZED,
-                detail="Authentication required",
-                headers={"WWW-Authenticate": "Bearer"},
-            )
+        # Check for API key first (X-API-Key header)
+        if x_api_key:
+            api_key = await _validate_api_key(db, x_api_key)
+            if api_key:
+                return None  # API key valid, allow access
 
-        try:
+        # Check for Bearer token (could be JWT or API key)
+        if credentials is not None:
             token = credentials.credentials
-            payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
-            username: str = payload.get("sub")
-            if username is None:
+            # Check if it's an API key (starts with bb_)
+            if token.startswith("bb_"):
+                api_key = await _validate_api_key(db, token)
+                if api_key:
+                    return None  # API key valid, allow access
+                raise HTTPException(
+                    status_code=status.HTTP_401_UNAUTHORIZED,
+                    detail="Invalid API key",
+                    headers={"WWW-Authenticate": "Bearer"},
+                )
+
+            # Otherwise treat as JWT
+            try:
+                payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
+                username: str = payload.get("sub")
+                if username is None:
+                    raise HTTPException(
+                        status_code=status.HTTP_401_UNAUTHORIZED,
+                        detail="Could not validate credentials",
+                        headers={"WWW-Authenticate": "Bearer"},
+                    )
+            except JWTError:
                 raise HTTPException(
                     status_code=status.HTTP_401_UNAUTHORIZED,
                     detail="Could not validate credentials",
                     headers={"WWW-Authenticate": "Bearer"},
                 )
-        except JWTError:
-            raise HTTPException(
-                status_code=status.HTTP_401_UNAUTHORIZED,
-                detail="Could not validate credentials",
-                headers={"WWW-Authenticate": "Bearer"},
-            )
 
-        user = await get_user_by_username(db, username)
-        if user is None or not user.is_active:
-            raise HTTPException(
-                status_code=status.HTTP_401_UNAUTHORIZED,
-                detail="Could not validate credentials",
-                headers={"WWW-Authenticate": "Bearer"},
-            )
-        return user
+            user = await get_user_by_username(db, username)
+            if user is None or not user.is_active:
+                raise HTTPException(
+                    status_code=status.HTTP_401_UNAUTHORIZED,
+                    detail="Could not validate credentials",
+                    headers={"WWW-Authenticate": "Bearer"},
+                )
+            return user
+
+        # No credentials provided
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="Authentication required",
+            headers={"WWW-Authenticate": "Bearer"},
+        )
 
 
 def require_role(required_role: str):
@@ -420,6 +468,9 @@ def RequireAdminIfAuthEnabled():
 def require_permission(*permissions: str | Permission):
     """Dependency factory that requires user to have ALL specified permissions.
 
+    Accepts both JWT tokens (via Authorization: Bearer header) and API keys
+    (via X-API-Key header or Authorization: Bearer bb_xxx).
+
     Args:
         *permissions: Permission strings or Permission enum values to require
 
@@ -431,25 +482,45 @@ def require_permission(*permissions: str | Permission):
 
     async def permission_checker(
         credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
-    ) -> User:
-        credentials_exception = HTTPException(
-            status_code=status.HTTP_401_UNAUTHORIZED,
-            detail="Could not validate credentials",
-            headers={"WWW-Authenticate": "Bearer"},
-        )
-        if credentials is None:
-            raise credentials_exception
+        x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
+    ) -> User | None:
+        async with async_session() as db:
+            # Check for API key first (X-API-Key header)
+            if x_api_key:
+                api_key = await _validate_api_key(db, x_api_key)
+                if api_key:
+                    return None  # API key valid, allow access
+
+            credentials_exception = HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail="Could not validate credentials",
+                headers={"WWW-Authenticate": "Bearer"},
+            )
+
+            if credentials is None:
+                raise credentials_exception
 
-        try:
             token = credentials.credentials
-            payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
-            username: str = payload.get("sub")
-            if username is None:
+            # Check if it's an API key (starts with bb_)
+            if token.startswith("bb_"):
+                api_key = await _validate_api_key(db, token)
+                if api_key:
+                    return None  # API key valid, allow access
+                raise HTTPException(
+                    status_code=status.HTTP_401_UNAUTHORIZED,
+                    detail="Invalid API key",
+                    headers={"WWW-Authenticate": "Bearer"},
+                )
+
+            # Otherwise treat as JWT
+            try:
+                payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
+                username: str = payload.get("sub")
+                if username is None:
+                    raise credentials_exception
+            except JWTError:
                 raise credentials_exception
-        except JWTError:
-            raise credentials_exception
 
-        async with async_session() as db:
             user = await get_user_by_username(db, username)
             if user is None or not user.is_active:
                 raise credentials_exception
@@ -468,6 +539,8 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
     """Dependency factory that checks permissions only if auth is enabled.
 
     This provides backward compatibility - when auth is disabled, all access is allowed.
+    Accepts both JWT tokens (via Authorization: Bearer header) and API keys
+    (via X-API-Key header or Authorization: Bearer bb_xxx).
 
     Args:
         *permissions: Permission strings or Permission enum values to require
@@ -480,50 +553,71 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
 
     async def permission_checker(
         credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+        x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
     ) -> User | None:
         async with async_session() as db:
             auth_enabled = await is_auth_enabled(db)
             if not auth_enabled:
                 return None  # Auth disabled, allow access
 
-            if credentials is None:
-                raise HTTPException(
-                    status_code=status.HTTP_401_UNAUTHORIZED,
-                    detail="Authentication required",
-                    headers={"WWW-Authenticate": "Bearer"},
-                )
+            # Check for API key first (X-API-Key header)
+            if x_api_key:
+                api_key = await _validate_api_key(db, x_api_key)
+                if api_key:
+                    return None  # API key valid, allow access
 
-            try:
+            # Check for Bearer token (could be JWT or API key)
+            if credentials is not None:
                 token = credentials.credentials
-                payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
-                username: str = payload.get("sub")
-                if username is None:
+                # Check if it's an API key (starts with bb_)
+                if token.startswith("bb_"):
+                    api_key = await _validate_api_key(db, token)
+                    if api_key:
+                        return None  # API key valid, allow access
+                    raise HTTPException(
+                        status_code=status.HTTP_401_UNAUTHORIZED,
+                        detail="Invalid API key",
+                        headers={"WWW-Authenticate": "Bearer"},
+                    )
+
+                # Otherwise treat as JWT
+                try:
+                    payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
+                    username: str = payload.get("sub")
+                    if username is None:
+                        raise HTTPException(
+                            status_code=status.HTTP_401_UNAUTHORIZED,
+                            detail="Could not validate credentials",
+                            headers={"WWW-Authenticate": "Bearer"},
+                        )
+                except JWTError:
                     raise HTTPException(
                         status_code=status.HTTP_401_UNAUTHORIZED,
                         detail="Could not validate credentials",
                         headers={"WWW-Authenticate": "Bearer"},
                     )
-            except JWTError:
-                raise HTTPException(
-                    status_code=status.HTTP_401_UNAUTHORIZED,
-                    detail="Could not validate credentials",
-                    headers={"WWW-Authenticate": "Bearer"},
-                )
 
-            user = await get_user_by_username(db, username)
-            if user is None or not user.is_active:
-                raise HTTPException(
-                    status_code=status.HTTP_401_UNAUTHORIZED,
-                    detail="Could not validate credentials",
-                    headers={"WWW-Authenticate": "Bearer"},
-                )
+                user = await get_user_by_username(db, username)
+                if user is None or not user.is_active:
+                    raise HTTPException(
+                        status_code=status.HTTP_401_UNAUTHORIZED,
+                        detail="Could not validate credentials",
+                        headers={"WWW-Authenticate": "Bearer"},
+                    )
 
-            if not user.has_all_permissions(*perm_strings):
-                raise HTTPException(
-                    status_code=status.HTTP_403_FORBIDDEN,
-                    detail=f"Missing required permissions: {', '.join(perm_strings)}",
-                )
-            return user
+                if not user.has_all_permissions(*perm_strings):
+                    raise HTTPException(
+                        status_code=status.HTTP_403_FORBIDDEN,
+                        detail=f"Missing required permissions: {', '.join(perm_strings)}",
+                    )
+                return user
+
+            # No credentials provided
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail="Authentication required",
+                headers={"WWW-Authenticate": "Bearer"},
+            )
 
     return permission_checker
 
@@ -547,6 +641,7 @@ def require_ownership_permission(
     - User with `all_permission` can modify any item
     - User with `own_permission` can only modify items where created_by_id == user.id
     - Ownerless items (created_by_id = null) require `all_permission`
+    - API keys (via X-API-Key header or Bearer bb_xxx) get full access (can_modify_all=True)
 
     Returns:
         A dependency function that returns (user, can_modify_all).
@@ -558,6 +653,7 @@ def require_ownership_permission(
 
     async def checker(
         credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+        x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
     ) -> tuple[User | None, bool]:
         """Returns (user, can_modify_all).
 
@@ -569,46 +665,66 @@ def require_ownership_permission(
             if not auth_enabled:
                 return None, True  # Auth disabled, allow all
 
-            if credentials is None:
-                raise HTTPException(
-                    status_code=status.HTTP_401_UNAUTHORIZED,
-                    detail="Authentication required",
-                    headers={"WWW-Authenticate": "Bearer"},
-                )
+            # Check for API key first (X-API-Key header)
+            if x_api_key:
+                api_key = await _validate_api_key(db, x_api_key)
+                if api_key:
+                    return None, True  # API key valid, allow all
 
-            try:
+            # Check for Bearer token (could be JWT or API key)
+            if credentials is not None:
                 token = credentials.credentials
-                payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
-                username: str = payload.get("sub")
-                if username is None:
+                # Check if it's an API key (starts with bb_)
+                if token.startswith("bb_"):
+                    api_key = await _validate_api_key(db, token)
+                    if api_key:
+                        return None, True  # API key valid, allow all
+                    raise HTTPException(
+                        status_code=status.HTTP_401_UNAUTHORIZED,
+                        detail="Invalid API key",
+                        headers={"WWW-Authenticate": "Bearer"},
+                    )
+
+                # Otherwise treat as JWT
+                try:
+                    payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
+                    username: str = payload.get("sub")
+                    if username is None:
+                        raise HTTPException(
+                            status_code=status.HTTP_401_UNAUTHORIZED,
+                            detail="Could not validate credentials",
+                            headers={"WWW-Authenticate": "Bearer"},
+                        )
+                except JWTError:
                     raise HTTPException(
                         status_code=status.HTTP_401_UNAUTHORIZED,
                         detail="Could not validate credentials",
                         headers={"WWW-Authenticate": "Bearer"},
                     )
-            except JWTError:
-                raise HTTPException(
-                    status_code=status.HTTP_401_UNAUTHORIZED,
-                    detail="Could not validate credentials",
-                    headers={"WWW-Authenticate": "Bearer"},
-                )
 
-            user = await get_user_by_username(db, username)
-            if user is None or not user.is_active:
+                user = await get_user_by_username(db, username)
+                if user is None or not user.is_active:
+                    raise HTTPException(
+                        status_code=status.HTTP_401_UNAUTHORIZED,
+                        detail="Could not validate credentials",
+                        headers={"WWW-Authenticate": "Bearer"},
+                    )
+
+                if user.has_permission(all_perm):
+                    return user, True
+                if user.has_permission(own_perm):
+                    return user, False
+
                 raise HTTPException(
-                    status_code=status.HTTP_401_UNAUTHORIZED,
-                    detail="Could not validate credentials",
-                    headers={"WWW-Authenticate": "Bearer"},
+                    status_code=status.HTTP_403_FORBIDDEN,
+                    detail=f"Missing permission: {own_perm} or {all_perm}",
                 )
 
-            if user.has_permission(all_perm):
-                return user, True
-            if user.has_permission(own_perm):
-                return user, False
-
+            # No credentials provided
             raise HTTPException(
-                status_code=status.HTTP_403_FORBIDDEN,
-                detail=f"Missing permission: {own_perm} or {all_perm}",
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail="Authentication required",
+                headers={"WWW-Authenticate": "Bearer"},
             )
 
     return checker

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

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

+ 122 - 11
backend/app/main.py

@@ -1,7 +1,7 @@
 import asyncio
 import logging
 from contextlib import asynccontextmanager
-from datetime import UTC, datetime, timedelta
+from datetime import datetime, timedelta, timezone
 from logging.handlers import RotatingFileHandler
 
 
@@ -456,8 +456,19 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
                     # remaining_time is in minutes, convert to seconds for notification
                     remaining_time_seconds = state.remaining_time * 60 if state.remaining_time else None
 
+                    # Capture camera snapshot for notification image attachment
+                    image_data = await _capture_snapshot_for_notification(
+                        printer_id, printer, logging.getLogger(__name__)
+                    )
+
                     await notification_service.on_print_progress(
-                        printer_id, printer_name, filename, current_milestone, db, remaining_time_seconds
+                        printer_id,
+                        printer_name,
+                        filename,
+                        current_milestone,
+                        db,
+                        remaining_time_seconds,
+                        image_data=image_data,
                     )
             except Exception as e:
                 logging.getLogger(__name__).warning(f"Progress milestone notification failed: {e}")
@@ -477,7 +488,8 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
 
         if new_error_codes:
             # Get the actual new errors for the notification
-            new_errors = [e for e in current_hms_errors if f"{e.attr:08x}" in new_error_codes]
+            # Filter to severity >= 2 (skip informational/status messages like H2D sends)
+            new_errors = [e for e in current_hms_errors if f"{e.attr:08x}" in new_error_codes and e.severity >= 2]
 
             try:
                 async with async_session() as db:
@@ -499,11 +511,18 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
 
                     from backend.app.services.hms_errors import get_error_description
 
+                    # Capture camera snapshot once for all error notifications
+                    error_image_data = await _capture_snapshot_for_notification(
+                        printer_id, printer, logging.getLogger(__name__)
+                    )
+
                     for error in new_errors:
                         module_name = module_names.get(error.module, f"Module 0x{error.module:02X}")
                         # Build short code like "0700_8010"
+                        # Mask to 16 bits to handle printers that send larger values
                         error_code_int = int(error.code.replace("0x", ""), 16) if error.code else 0
-                        short_code = f"{(error.attr >> 16) & 0xFFFF:04X}_{error_code_int:04X}"
+                        error_code_masked = error_code_int & 0xFFFF
+                        short_code = f"{(error.attr >> 16) & 0xFFFF:04X}_{error_code_masked:04X}"
 
                         error_type = f"{module_name} Error"
                         # Look up human-readable description
@@ -511,7 +530,7 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
                         error_detail = description if description else f"Error code: {short_code}"
 
                         await notification_service.on_printer_error(
-                            printer_id, printer_name, error_type, db, error_detail
+                            printer_id, printer_name, error_type, db, error_detail, image_data=error_image_data
                         )
 
                     logging.getLogger(__name__).info(
@@ -639,6 +658,63 @@ async def on_ams_change(printer_id: int, ams_data: list):
         logging.getLogger(__name__).warning(f"Spoolman AMS sync failed: {e}")
 
 
+async def _capture_snapshot_for_notification(printer_id: int, printer, logger) -> bytes | None:
+    """Capture a camera snapshot for notification image attachment.
+
+    Returns JPEG bytes (max 2.5MB) or None if capture fails or is unavailable.
+    Uses: external camera > buffered frame > fresh capture.
+    """
+    if not printer:
+        return None
+
+    try:
+        from backend.app.api.routes.settings import get_setting
+
+        async with async_session() as db:
+            capture_enabled = await get_setting(db, "capture_finish_photo")
+
+        if capture_enabled is not None and capture_enabled.lower() != "true":
+            return None
+
+        # Try external camera first
+        if printer.external_camera_enabled and printer.external_camera_url:
+            logger.info(f"[SNAPSHOT] Capturing from external camera for printer {printer_id}")
+            from backend.app.services.external_camera import capture_frame
+
+            frame_data = await capture_frame(printer.external_camera_url, printer.external_camera_type or "mjpeg")
+            if frame_data and len(frame_data) <= 2_500_000:
+                logger.info(f"[SNAPSHOT] External camera frame: {len(frame_data)} bytes")
+                return frame_data
+
+        # Try buffered frame from active stream
+        from backend.app.api.routes.camera import _active_chamber_streams, _active_streams, get_buffered_frame
+
+        active_for_printer = [k for k in _active_streams if k.startswith(f"{printer_id}-")]
+        active_chamber = [k for k in _active_chamber_streams if k.startswith(f"{printer_id}-")]
+        buffered_frame = get_buffered_frame(printer_id)
+
+        if (active_for_printer or active_chamber) and buffered_frame:
+            logger.info(f"[SNAPSHOT] Using buffered frame for printer {printer_id}: {len(buffered_frame)} bytes")
+            if len(buffered_frame) <= 2_500_000:
+                return buffered_frame
+
+        # Fresh capture from printer camera
+        logger.info(f"[SNAPSHOT] Capturing fresh frame for printer {printer_id}")
+        from backend.app.services.camera import capture_camera_frame_bytes
+
+        frame_data = await capture_camera_frame_bytes(
+            printer.ip_address, printer.access_code, printer.model, timeout=15
+        )
+        if frame_data and len(frame_data) <= 2_500_000:
+            logger.info(f"[SNAPSHOT] Fresh camera frame: {len(frame_data)} bytes")
+            return frame_data
+
+    except Exception as e:
+        logger.warning(f"[SNAPSHOT] Failed to capture snapshot for printer {printer_id}: {e}")
+
+    return None
+
+
 async def _send_print_start_notification(
     printer_id: int,
     data: dict,
@@ -658,6 +734,14 @@ async def _send_print_start_notification(
             result = await db.execute(select(Printer).where(Printer.id == printer_id))
             printer = result.scalar_one_or_none()
             printer_name = printer.name if printer else f"Printer {printer_id}"
+
+            # Capture camera snapshot for notification image attachment
+            image_data = await _capture_snapshot_for_notification(printer_id, printer, logger)
+            if image_data:
+                if archive_data is None:
+                    archive_data = {}
+                archive_data["image_data"] = image_data
+
             await notification_service.on_print_start(printer_id, printer_name, data, db, archive_data=archive_data)
     except Exception as e:
         logger.warning(f"Notification on_print_start failed: {e}")
@@ -727,7 +811,11 @@ async def on_print_start(printer_id: int, data: dict):
         printer = result.scalar_one_or_none()
 
         # Plate detection check - pause if objects detected on build plate
+        logger.info(
+            f"[PLATE CHECK] printer_id={printer_id}, plate_detection_enabled={printer.plate_detection_enabled if printer else 'NO PRINTER'}"
+        )
         if printer and printer.plate_detection_enabled:
+            logger.info(f"[PLATE CHECK] ENTERING plate detection code for printer {printer_id}")
             try:
                 from backend.app.services.plate_detection import check_plate_empty
 
@@ -939,7 +1027,7 @@ async def on_print_start(printer_id: int, data: dict):
         if existing_archive:
             # Check if archive is stale (older than 4 hours) - likely a failed/cancelled print
             # that didn't get properly updated
-            archive_age = datetime.now(UTC) - existing_archive.created_at.replace(tzinfo=UTC)
+            archive_age = datetime.now(timezone.utc) - existing_archive.created_at.replace(tzinfo=timezone.utc)
             if archive_age.total_seconds() > 4 * 60 * 60:  # 4 hours
                 logger.warning(
                     f"Found stale 'printing' archive {existing_archive.id} (age: {archive_age}), "
@@ -1851,7 +1939,7 @@ async def on_print_complete(printer_id: int, data: dict):
                             "actual_filament_grams": archive.filament_used_grams,
                             "failure_reason": archive.failure_reason,
                         }
-                        # Add finish photo URL if available
+                        # Add finish photo URL and image bytes if available
                         if finish_photo_filename:
                             from backend.app.api.routes.settings import get_setting
 
@@ -1867,6 +1955,29 @@ async def on_print_complete(printer_id: int, data: dict):
                                     f"/api/v1/archives/{archive_id}/photos/{finish_photo_filename}"
                                 )
 
+                            # Read finish photo bytes for image attachment (e.g. Pushover)
+                            try:
+                                from pathlib import Path
+
+                                photo_path = (
+                                    app_settings.base_dir
+                                    / Path(archive.file_path).parent
+                                    / "photos"
+                                    / finish_photo_filename
+                                )
+                                if photo_path.exists():
+                                    photo_bytes = await asyncio.to_thread(photo_path.read_bytes)
+                                    if len(photo_bytes) <= 2_500_000:
+                                        archive_data["image_data"] = photo_bytes
+                                        logger.info(f"[NOTIFY-BG] Loaded finish photo bytes: {len(photo_bytes)} bytes")
+                                    else:
+                                        logger.warning(
+                                            f"[NOTIFY-BG] Finish photo too large for attachment: "
+                                            f"{len(photo_bytes)} bytes"
+                                        )
+                            except Exception as e:
+                                logger.warning(f"[NOTIFY-BG] Failed to read finish photo bytes: {e}")
+
                 await notification_service.on_print_complete(
                     printer_id, printer_name, print_status, data, db, archive_data=archive_data
                 )
@@ -2423,8 +2534,6 @@ async def lifespan(app: FastAPI):
 
         # Restore MQTT smart plug subscriptions
         if mqtt_settings.get("mqtt_enabled"):
-            from sqlalchemy import select
-
             from backend.app.models.smart_plug import SmartPlug
 
             result = await db.execute(select(SmartPlug).where(SmartPlug.plug_type == "mqtt"))
@@ -2501,6 +2610,7 @@ async def lifespan(app: FastAPI):
             vp_mode = await get_setting(db, "virtual_printer_mode") or "immediate"
             vp_model = await get_setting(db, "virtual_printer_model") or ""
             vp_target_printer_id = await get_setting(db, "virtual_printer_target_printer_id")
+            vp_remote_iface = await get_setting(db, "virtual_printer_remote_interface_ip") or ""
 
             # Look up printer IP and serial if in proxy mode
             vp_target_ip = ""
@@ -2526,6 +2636,7 @@ async def lifespan(app: FastAPI):
                         model=vp_model,
                         target_printer_ip=vp_target_ip,
                         target_printer_serial=vp_target_serial,
+                        remote_interface_ip=vp_remote_iface,
                     )
                     if vp_mode == "proxy":
                         logging.info(f"Virtual printer proxy started (target={vp_target_ip})")
@@ -2662,9 +2773,9 @@ async def auth_middleware(request, call_next):
         )
 
     # Validate JWT token
-    try:
-        import jwt
+    import jwt
 
+    try:
         from backend.app.core.auth import ALGORITHM, SECRET_KEY
 
         token = auth_header.replace("Bearer ", "")

+ 7 - 0
backend/app/schemas/library.py

@@ -130,6 +130,12 @@ class FileResponse(BaseModel):
     created_at: datetime
     updated_at: datetime
 
+    # Metadata fields
+    print_name: str | None = None
+    print_time_seconds: int | None = None
+    filament_used_grams: float | None = None
+    sliced_for_model: str | None = None
+
     class Config:
         from_attributes = True
 
@@ -154,6 +160,7 @@ class FileListResponse(BaseModel):
     print_name: str | None = None
     print_time_seconds: int | None = None
     filament_used_grams: float | None = None
+    sliced_for_model: str | None = None
 
     class Config:
         from_attributes = True

+ 1 - 1
backend/app/services/archive.py

@@ -6,8 +6,8 @@ import shutil
 import zipfile
 from datetime import datetime
 from pathlib import Path
-from xml.etree import ElementTree as ET
 
+from defusedxml import ElementTree as ET
 from sqlalchemy import and_, or_, select
 from sqlalchemy.ext.asyncio import AsyncSession
 

+ 172 - 33
backend/app/services/bambu_ftp.py

@@ -77,9 +77,16 @@ class BambuFTPClient:
 
     FTP_PORT = 990
     DEFAULT_TIMEOUT = 30  # Default timeout in seconds (increased for A1 printers)
-    # Models that need SSL session reuse disabled (A1 series has FTP issues with session reuse)
-    # P2S should use normal session reuse like X1C/P1S, not skip it
-    SKIP_SESSION_REUSE_MODELS = ("A1", "A1 Mini", "P1S", "P1P")
+    # Models that may need SSL mode fallback (try prot_p first, fall back to prot_c)
+    # These models have varying FTP SSL behavior depending on firmware version
+    A1_MODELS = ("A1", "A1 Mini")
+    # Chunk size for manual upload transfer (1MB)
+    # Larger chunks reduce overhead and work better with A1 printers
+    CHUNK_SIZE = 1024 * 1024
+
+    # Cache for working FTP modes per printer IP
+    # Maps IP -> "prot_p" or "prot_c"
+    _mode_cache: dict[str, str] = {}
 
     def __init__(
         self,
@@ -87,38 +94,70 @@ class BambuFTPClient:
         access_code: str,
         timeout: float | None = None,
         printer_model: str | None = None,
+        force_prot_c: bool = False,
     ):
         self.ip_address = ip_address
         self.access_code = access_code
         self.timeout = timeout if timeout is not None else self.DEFAULT_TIMEOUT
         self.printer_model = printer_model
+        self.force_prot_c = force_prot_c
         self._ftp: ImplicitFTP_TLS | None = None
 
-    def _should_skip_session_reuse(self) -> bool:
-        """Check if this printer model needs SSL session reuse disabled."""
+    def _is_a1_model(self) -> bool:
+        """Check if this is an A1 series printer."""
         if not self.printer_model:
             return False
-        return self.printer_model in self.SKIP_SESSION_REUSE_MODELS
+        return self.printer_model in self.A1_MODELS
+
+    def _get_cached_mode(self) -> str | None:
+        """Get cached FTP mode for this printer."""
+        return self._mode_cache.get(self.ip_address)
+
+    @classmethod
+    def cache_mode(cls, ip_address: str, mode: str):
+        """Cache the working FTP mode for a printer."""
+        cls._mode_cache[ip_address] = mode
+        logger.info(f"FTP mode cached for {ip_address}: {mode}")
+
+    def _should_use_prot_c(self) -> bool:
+        """Determine if we should use prot_c (clear) mode."""
+        # If explicitly forced, use prot_c
+        if self.force_prot_c:
+            return True
+        # Check cache first
+        cached = self._get_cached_mode()
+        if cached:
+            return cached == "prot_c"
+        # Default: try prot_p first (will fall back if needed)
+        return False
 
     def connect(self) -> bool:
         """Connect to the printer FTP server (implicit FTPS on port 990)."""
         try:
-            skip_reuse = self._should_skip_session_reuse()
+            use_prot_c = self._should_use_prot_c()
             logger.debug(
                 f"FTP connecting to {self.ip_address}:{self.FTP_PORT} "
-                f"(timeout={self.timeout}s, model={self.printer_model}, skip_session_reuse={skip_reuse})"
+                f"(timeout={self.timeout}s, model={self.printer_model}, prot_c={use_prot_c})"
             )
-            self._ftp = ImplicitFTP_TLS(skip_session_reuse=skip_reuse)
+            self._ftp = ImplicitFTP_TLS(skip_session_reuse=use_prot_c)
             self._ftp.connect(self.ip_address, self.FTP_PORT, timeout=self.timeout)
             logger.debug("FTP connected, logging in as bblp")
             self._ftp.login("bblp", self.access_code)
-            logger.debug("FTP logged in, setting prot_p and passive mode")
-            self._ftp.prot_p()
+            if use_prot_c:
+                # Use clear (unencrypted) data channel
+                logger.debug("FTP logged in, setting prot_c (clear) and passive mode")
+                self._ftp.prot_c()
+            else:
+                # Use protected (encrypted) data channel with session reuse
+                logger.debug("FTP logged in, setting prot_p (protected) and passive mode")
+                self._ftp.prot_p()
             self._ftp.set_pasv(True)
             # Log welcome message for debugging
             if hasattr(self._ftp, "welcome") and self._ftp.welcome:
                 logger.debug(f"FTP server welcome: {self._ftp.welcome}")
-            logger.info(f"FTP connected successfully to {self.ip_address} (model={self.printer_model})")
+            logger.info(
+                f"FTP connected successfully to {self.ip_address} (model={self.printer_model}, prot_c={use_prot_c})"
+            )
             return True
         except ftplib.error_perm as e:
             logger.warning(f"FTP connection permission error to {self.ip_address}: {e}")
@@ -327,18 +366,37 @@ class BambuFTPClient:
 
             uploaded = 0
 
-            def on_block(block: bytes):
-                nonlocal uploaded
-                uploaded += len(block)
-                if progress_callback:
-                    progress_callback(uploaded, file_size)
-
+            # Use manual transfer instead of storbinary() for A1 compatibility
+            # A1 printers have issues with storbinary's voidresp() hanging after transfer
             with open(local_path, "rb") as f:
-                if self._should_skip_session_reuse():
-                    ftplib._SSLSocket = None
-
                 logger.debug(f"FTP STOR command starting for {remote_path}")
-                self._ftp.storbinary(f"STOR {remote_path}", f, callback=on_block)
+                conn = self._ftp.transfercmd(f"STOR {remote_path}")
+
+                # Set explicit socket options for reliable transfer
+                conn.setblocking(True)
+                conn.settimeout(120)  # 2 minute timeout per chunk
+
+                try:
+                    while True:
+                        chunk = f.read(self.CHUNK_SIZE)
+                        if not chunk:
+                            logger.debug("FTP upload: final chunk reached")
+                            break
+
+                        conn.sendall(chunk)
+                        uploaded += len(chunk)
+                        logger.debug(f"FTP upload progress: {uploaded}/{file_size} bytes")
+
+                        if progress_callback:
+                            progress_callback(uploaded, file_size)
+
+                except OSError as e:
+                    logger.error(f"FTP connection lost during upload: {e}")
+                    conn.close()
+                    raise
+
+                conn.close()
+
             logger.info(f"FTP upload complete: {remote_path}")
             return True
         except ftplib.error_perm as e:
@@ -366,8 +424,24 @@ class BambuFTPClient:
             return False
 
         try:
-            buffer = BytesIO(data)
-            self._ftp.storbinary(f"STOR {remote_path}", buffer)
+            # Use manual transfer instead of storbinary() for A1 compatibility
+            conn = self._ftp.transfercmd(f"STOR {remote_path}")
+            conn.setblocking(True)
+            conn.settimeout(120)
+
+            try:
+                # Send data in chunks
+                offset = 0
+                while offset < len(data):
+                    chunk = data[offset : offset + self.CHUNK_SIZE]
+                    conn.sendall(chunk)
+                    offset += len(chunk)
+            except OSError as e:
+                logger.error(f"FTP connection lost during upload_bytes: {e}")
+                conn.close()
+                raise
+
+            conn.close()
             return True
         except Exception:
             return False
@@ -458,6 +532,9 @@ async def download_file_async(
 ) -> bool:
     """Async wrapper for downloading a file with timeout.
 
+    For A1/A1 Mini printers, automatically tries prot_p first, then falls back
+    to prot_c if the download fails. The working mode is cached for future operations.
+
     Args:
         ip_address: Printer IP address
         access_code: Printer access code
@@ -468,18 +545,47 @@ async def download_file_async(
         printer_model: Printer model for A1-specific workarounds
     """
     loop = asyncio.get_event_loop()
+    is_a1 = printer_model in BambuFTPClient.A1_MODELS if printer_model else False
 
-    def _download():
-        client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
+    def _download(force_prot_c: bool = False) -> bool:
+        mode_str = "prot_c" if force_prot_c else "prot_p"
+        client = BambuFTPClient(
+            ip_address, access_code, timeout=socket_timeout, printer_model=printer_model, force_prot_c=force_prot_c
+        )
         if client.connect():
             try:
-                return client.download_to_file(remote_path, local_path)
+                result = client.download_to_file(remote_path, local_path)
+                if result:
+                    # Cache the working mode
+                    BambuFTPClient.cache_mode(ip_address, mode_str)
+                return result
             finally:
                 client.disconnect()
         return False
 
     try:
-        return await asyncio.wait_for(loop.run_in_executor(None, _download), timeout=timeout)
+        # Check if we have a cached mode for this printer
+        cached_mode = BambuFTPClient._mode_cache.get(ip_address)
+
+        if cached_mode:
+            # Use cached mode
+            force_prot_c = cached_mode == "prot_c"
+            return await asyncio.wait_for(loop.run_in_executor(None, lambda: _download(force_prot_c)), timeout=timeout)
+
+        # No cached mode - try prot_p first
+        result = await asyncio.wait_for(loop.run_in_executor(None, lambda: _download(False)), timeout=timeout)
+
+        if result:
+            return True
+
+        # Download failed - for A1 models, try prot_c fallback
+        if is_a1:
+            logger.info("FTP download failed with prot_p for A1 model, trying prot_c fallback...")
+            result = await asyncio.wait_for(loop.run_in_executor(None, lambda: _download(True)), timeout=timeout)
+            return result
+
+        return False
+
     except TimeoutError:
         logger.warning(f"FTP download timed out after {timeout}s for {remote_path}")
         return False
@@ -526,6 +632,9 @@ async def upload_file_async(
 ) -> bool:
     """Async wrapper for uploading a file with timeout and progress callback.
 
+    For A1/A1 Mini printers, automatically tries prot_p first, then falls back
+    to prot_c if the upload fails. The working mode is cached for future uploads.
+
     Args:
         ip_address: Printer IP address
         access_code: Printer access code
@@ -537,23 +646,53 @@ async def upload_file_async(
         printer_model: Printer model for A1-specific workarounds
     """
     loop = asyncio.get_event_loop()
+    is_a1 = printer_model in BambuFTPClient.A1_MODELS if printer_model else False
 
-    def _upload():
+    def _upload(force_prot_c: bool = False) -> bool:
+        mode_str = "prot_c" if force_prot_c else "prot_p"
         logger.info(
-            f"FTP connecting to {ip_address} for upload (model={printer_model}, socket_timeout={socket_timeout}s)..."
+            f"FTP connecting to {ip_address} for upload (model={printer_model}, "
+            f"mode={mode_str}, socket_timeout={socket_timeout}s)..."
+        )
+        client = BambuFTPClient(
+            ip_address, access_code, timeout=socket_timeout, printer_model=printer_model, force_prot_c=force_prot_c
         )
-        client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
         if client.connect():
             logger.info(f"FTP connected to {ip_address}")
             try:
-                return client.upload_file(local_path, remote_path, progress_callback)
+                result = client.upload_file(local_path, remote_path, progress_callback)
+                if result:
+                    # Cache the working mode
+                    BambuFTPClient.cache_mode(ip_address, mode_str)
+                return result
             finally:
                 client.disconnect()
         logger.warning(f"FTP connection failed to {ip_address}")
         return False
 
     try:
-        return await asyncio.wait_for(loop.run_in_executor(None, _upload), timeout=timeout)
+        # Check if we have a cached mode for this printer
+        cached_mode = BambuFTPClient._mode_cache.get(ip_address)
+
+        if cached_mode:
+            # Use cached mode
+            force_prot_c = cached_mode == "prot_c"
+            return await asyncio.wait_for(loop.run_in_executor(None, lambda: _upload(force_prot_c)), timeout=timeout)
+
+        # No cached mode - try prot_p first
+        result = await asyncio.wait_for(loop.run_in_executor(None, lambda: _upload(False)), timeout=timeout)
+
+        if result:
+            return True
+
+        # Upload failed - for A1 models, try prot_c fallback
+        if is_a1:
+            logger.info("FTP upload failed with prot_p for A1 model, trying prot_c fallback...")
+            result = await asyncio.wait_for(loop.run_in_executor(None, lambda: _upload(True)), timeout=timeout)
+            return result
+
+        return False
+
     except TimeoutError:
         logger.warning(f"FTP upload timed out after {timeout}s for {remote_path}")
         return False

+ 13 - 6
backend/app/services/bambu_mqtt.py

@@ -2049,6 +2049,10 @@ class BambuMQTTClient:
                         slot_id = tray_id % 4
                         ams_mapping2.append({"ams_id": ams_id, "slot_id": slot_id})
 
+            # H2D series requires integer values (0/1) for boolean fields
+            # Other printers (X1C, P1S, A1, etc.) require actual booleans
+            is_h2d = self.model and self.model.upper().strip() in ("H2D", "H2D PRO", "H2DPRO", "H2C", "H2S")
+
             command = {
                 "print": {
                     "sequence_id": "20000",
@@ -2058,13 +2062,13 @@ class BambuMQTTClient:
                     "file": filename,
                     "md5": "",
                     "bed_type": "auto",
-                    "timelapse": timelapse,
-                    "bed_leveling": bed_levelling,
+                    "timelapse": (1 if timelapse else 0) if is_h2d else timelapse,
+                    "bed_leveling": (1 if bed_levelling else 0) if is_h2d else bed_levelling,
                     "auto_bed_leveling": 1 if bed_levelling else 0,
-                    "flow_cali": flow_cali,
-                    "vibration_cali": vibration_cali,
-                    "layer_inspect": layer_inspect,
-                    "use_ams": use_ams,
+                    "flow_cali": (1 if flow_cali else 0) if is_h2d else flow_cali,
+                    "vibration_cali": (1 if vibration_cali else 0) if is_h2d else vibration_cali,
+                    "layer_inspect": (1 if layer_inspect else 0) if is_h2d else layer_inspect,
+                    "use_ams": (1 if use_ams else 0) if is_h2d else use_ams,
                     "cfg": "0",
                     "extrude_cali_flag": 0,
                     "extrude_cali_manual_mode": 0,
@@ -2077,6 +2081,9 @@ class BambuMQTTClient:
                 }
             }
 
+            if is_h2d:
+                logger.info(f"[{self.serial_number}] H2D series detected: using integer format for boolean fields")
+
             # P2S-specific parameter adjustments
             # P2S printer doesn't support vibration calibration like X1/P1 series
             if self.model and self.model.upper().strip() in ("P2S", "N7"):

+ 62 - 44
backend/app/services/camera.py

@@ -301,11 +301,10 @@ async def capture_camera_frame(
     output_path: Path,
     timeout: int = 30,
 ) -> bool:
-    """Capture a single frame from the printer's camera stream.
+    """Capture a single frame from the printer's camera stream and save to disk.
 
-    Uses the appropriate protocol based on printer model:
-    - A1/P1: Chamber image protocol (port 6000)
-    - X1/H2/P2: RTSP via ffmpeg (port 322)
+    Uses capture_camera_frame_bytes() internally for protocol selection,
+    then writes the result to the specified output path.
 
     Args:
         ip_address: Printer IP address
@@ -317,36 +316,57 @@ async def capture_camera_frame(
     Returns:
         True if capture was successful, False otherwise
     """
-    # Ensure output directory exists
     output_path.parent.mkdir(parents=True, exist_ok=True)
 
-    # Use chamber image protocol for A1/P1 models
+    jpeg_data = await capture_camera_frame_bytes(ip_address, access_code, model, timeout)
+    if jpeg_data:
+        try:
+            with open(output_path, "wb") as f:
+                f.write(jpeg_data)
+            logger.info(f"Saved camera frame to: {output_path}")
+            return True
+        except Exception as e:
+            logger.error(f"Failed to write camera frame: {e}")
+            return False
+    return False
+
+
+async def capture_camera_frame_bytes(
+    ip_address: str,
+    access_code: str,
+    model: str | None,
+    timeout: int = 15,
+) -> bytes | None:
+    """Capture a single frame and return as JPEG bytes (no disk write).
+
+    Uses the same protocol selection as capture_camera_frame but returns
+    bytes directly instead of writing to disk.
+
+    Args:
+        ip_address: Printer IP address
+        access_code: Printer access code
+        model: Printer model (X1, H2D, P1, A1, etc.)
+        timeout: Timeout in seconds for the capture operation
+
+    Returns:
+        JPEG bytes if capture was successful, None otherwise
+    """
+    # Chamber image models: A1/P1 - returns bytes directly
     if is_chamber_image_model(model):
-        logger.info(f"Capturing camera frame from {ip_address} using chamber image protocol (model: {model})")
-        jpeg_data = await read_chamber_image_frame(ip_address, access_code, timeout=float(timeout))
-        if jpeg_data:
-            try:
-                with open(output_path, "wb") as f:
-                    f.write(jpeg_data)
-                logger.info(f"Successfully captured camera frame: {output_path}")
-                return True
-            except Exception as e:
-                logger.error(f"Failed to write camera frame: {e}")
-                return False
-        return False
-
-    # Use RTSP/ffmpeg for X1/H2/P2 models
+        logger.info(f"Capturing camera frame bytes from {ip_address} using chamber image protocol (model: {model})")
+        return await read_chamber_image_frame(ip_address, access_code, timeout=float(timeout))
+
+    # RTSP models: X1/H2/P2 - use ffmpeg piping to stdout
     camera_url = build_camera_url(ip_address, access_code, model)
 
     ffmpeg = get_ffmpeg_path()
     if not ffmpeg:
-        logger.error("ffmpeg not found. Please install ffmpeg to enable camera capture.")
-        return False
+        logger.error("ffmpeg not found for camera frame capture")
+        return None
 
-    # ffmpeg command to capture a single frame from RTSPS stream
     cmd = [
         ffmpeg,
-        "-y",  # Overwrite output
+        "-y",
         "-rtsp_transport",
         "tcp",
         "-rtsp_flags",
@@ -355,14 +375,16 @@ async def capture_camera_frame(
         camera_url,
         "-frames:v",
         "1",
-        "-update",
-        "1",
+        "-f",
+        "image2pipe",
+        "-vcodec",
+        "mjpeg",
         "-q:v",
         "2",
-        str(output_path),
+        "-",
     ]
 
-    logger.info(f"Capturing camera frame from {ip_address} using RTSP (model: {model})")
+    logger.info(f"Capturing camera frame bytes from {ip_address} using RTSP (model: {model})")
 
     try:
         process = await asyncio.create_subprocess_exec(
@@ -376,27 +398,23 @@ async def capture_camera_frame(
         except TimeoutError:
             process.kill()
             await process.wait()
-            logger.error(f"Camera capture timed out after {timeout}s")
-            return False
-
-        if process.returncode != 0:
-            stderr_text = stderr.decode() if stderr else "Unknown error"
-            logger.error(f"ffmpeg failed with code {process.returncode}: {stderr_text}")
-            return False
+            logger.error(f"Camera frame bytes capture timed out after {timeout}s")
+            return None
 
-        if output_path.exists() and output_path.stat().st_size > 0:
-            logger.info(f"Successfully captured camera frame: {output_path}")
-            return True
+        if process.returncode == 0 and stdout and len(stdout) >= 100:
+            logger.info(f"Successfully captured camera frame bytes: {len(stdout)} bytes")
+            return stdout
         else:
-            logger.error("Camera capture produced no output file")
-            return False
+            stderr_text = stderr.decode() if stderr else "Unknown error"
+            logger.error(f"ffmpeg frame bytes capture failed (code {process.returncode}): {stderr_text[:200]}")
+            return None
 
     except FileNotFoundError:
-        logger.error("ffmpeg not found. Please install ffmpeg to enable camera capture.")
-        return False
+        logger.error("ffmpeg not found for camera frame capture")
+        return None
     except Exception as e:
-        logger.exception(f"Camera capture failed: {e}")
-        return False
+        logger.exception(f"Camera frame bytes capture failed: {e}")
+        return None
 
 
 async def capture_finish_photo(

+ 12 - 12
backend/app/services/github_backup.py

@@ -9,7 +9,7 @@ import hashlib
 import json
 import logging
 import re
-from datetime import UTC, datetime, timedelta
+from datetime import datetime, timedelta, timezone
 
 import httpx
 from sqlalchemy import desc, select
@@ -85,19 +85,19 @@ class GitHubBackupService:
             )
             configs = result.scalars().all()
 
-            now = datetime.now(UTC)
+            now = datetime.now(timezone.utc)
             for config in configs:
                 # Handle both naive (from DB) and aware datetimes
                 next_run = config.next_scheduled_run
                 if next_run and next_run.tzinfo is None:
-                    next_run = next_run.replace(tzinfo=UTC)
+                    next_run = next_run.replace(tzinfo=timezone.utc)
                 if next_run and next_run <= now:
                     logger.info(f"Running scheduled backup for config {config.id}")
                     await self.run_backup(config.id, trigger="scheduled")
 
     def _calculate_next_run(self, schedule_type: str, from_time: datetime | None = None) -> datetime:
         """Calculate the next scheduled run time."""
-        now = from_time or datetime.now(UTC)
+        now = from_time or datetime.now(timezone.utc)
         interval = SCHEDULE_INTERVALS.get(schedule_type, SCHEDULE_INTERVALS["daily"])
         return now + timedelta(seconds=interval)
 
@@ -237,9 +237,9 @@ class GitHubBackupService:
                     if not backup_data:
                         # No data to backup
                         log.status = "skipped"
-                        log.completed_at = datetime.now(UTC)
+                        log.completed_at = datetime.now(timezone.utc)
                         log.error_message = "No data to backup"
-                        config.last_backup_at = datetime.now(UTC)
+                        config.last_backup_at = datetime.now(timezone.utc)
                         config.last_backup_status = "skipped"
                         config.last_backup_message = "No data to backup"
                         if config.schedule_enabled:
@@ -259,12 +259,12 @@ class GitHubBackupService:
 
                     # Update log and config
                     log.status = push_result["status"]
-                    log.completed_at = datetime.now(UTC)
+                    log.completed_at = datetime.now(timezone.utc)
                     log.commit_sha = push_result.get("commit_sha")
                     log.files_changed = push_result.get("files_changed", 0)
                     log.error_message = push_result.get("error")
 
-                    config.last_backup_at = datetime.now(UTC)
+                    config.last_backup_at = datetime.now(timezone.utc)
                     config.last_backup_status = push_result["status"]
                     config.last_backup_message = push_result.get("message", "")
                     config.last_backup_commit_sha = push_result.get("commit_sha")
@@ -285,10 +285,10 @@ class GitHubBackupService:
                 except Exception as e:
                     logger.error(f"Backup failed: {e}")
                     log.status = "failed"
-                    log.completed_at = datetime.now(UTC)
+                    log.completed_at = datetime.now(timezone.utc)
                     log.error_message = str(e)
 
-                    config.last_backup_at = datetime.now(UTC)
+                    config.last_backup_at = datetime.now(timezone.utc)
                     config.last_backup_status = "failed"
                     config.last_backup_message = str(e)
 
@@ -572,7 +572,7 @@ class GitHubBackupService:
             new_tree_sha = tree_response.json()["sha"]
 
             # Create commit
-            commit_message = f"Bambuddy backup - {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}"
+            commit_message = f"Bambuddy backup - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}"
             commit_response = await client.post(
                 f"https://api.github.com/repos/{owner}/{repo}/git/commits",
                 headers=headers,
@@ -689,7 +689,7 @@ class GitHubBackupService:
                 f"https://api.github.com/repos/{owner}/{repo}/git/commits",
                 headers=headers,
                 json={
-                    "message": f"Initial Bambuddy backup - {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}",
+                    "message": f"Initial Bambuddy backup - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}",
                     "tree": tree_sha,
                 },
             )

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

+ 50 - 11
backend/app/services/notification_service.py

@@ -211,8 +211,17 @@ class NotificationService:
         else:
             return False, f"HTTP {response.status_code}: {response.text[:200]}"
 
-    async def _send_pushover(self, config: dict, title: str, message: str) -> tuple[bool, str]:
-        """Send notification via Pushover."""
+    async def _send_pushover(
+        self, config: dict, title: str, message: str, image_data: bytes | None = None
+    ) -> tuple[bool, str]:
+        """Send notification via Pushover.
+
+        Args:
+            config: Provider configuration with user_key, app_token, priority
+            title: Notification title
+            message: Notification body
+            image_data: Optional JPEG image bytes to attach (max 2.5MB)
+        """
         user_key = config.get("user_key", "").strip()
         app_token = config.get("app_token", "").strip()
         priority = config.get("priority", 0)
@@ -230,7 +239,13 @@ class NotificationService:
         }
 
         client = await self._get_client()
-        response = await client.post(url, data=data)
+
+        if image_data:
+            # Pushover supports image attachments via multipart form-data
+            files = {"attachment": ("photo.jpg", image_data, "image/jpeg")}
+            response = await client.post(url, data=data, files=files)
+        else:
+            response = await client.post(url, data=data)
 
         if response.status_code == 200:
             return True, "Message sent successfully"
@@ -407,7 +422,12 @@ class NotificationService:
             return False, f"Webhook error: {str(e)}"
 
     async def _send_to_provider(
-        self, provider: NotificationProvider, title: str, message: str, db: AsyncSession | None = None
+        self,
+        provider: NotificationProvider,
+        title: str,
+        message: str,
+        db: AsyncSession | None = None,
+        image_data: bytes | None = None,
     ) -> tuple[bool, str]:
         """Send notification to a specific provider."""
         # Check quiet hours
@@ -423,7 +443,7 @@ class NotificationService:
             elif provider.provider_type == "ntfy":
                 return await self._send_ntfy(config, title, message)
             elif provider.provider_type == "pushover":
-                return await self._send_pushover(config, title, message)
+                return await self._send_pushover(config, title, message, image_data=image_data)
             elif provider.provider_type == "telegram":
                 return await self._send_telegram(config, f"*{title}*\n{message}")
             elif provider.provider_type == "email":
@@ -513,6 +533,7 @@ class NotificationService:
         printer_id: int | None = None,
         printer_name: str | None = None,
         force_immediate: bool = False,
+        image_data: bytes | None = None,
     ):
         """Send notification to multiple providers and log the results.
 
@@ -522,7 +543,7 @@ class NotificationService:
         for provider in providers:
             try:
                 # Always send notification immediately
-                success, error = await self._send_to_provider(provider, title, message, db)
+                success, error = await self._send_to_provider(provider, title, message, db, image_data=image_data)
 
                 # Also queue for digest if enabled (digest is a summary, not a queue)
                 if provider.daily_digest_enabled and provider.daily_digest_time:
@@ -629,9 +650,16 @@ class NotificationService:
             "estimated_time": time_str,
         }
 
+        # Extract image data for providers that support attachments (e.g. Pushover)
+        image_data = None
+        if archive_data:
+            image_data = archive_data.get("image_data")
+
         logger.info(f"Found {len(providers)} providers for print_start: {[p.name for p in providers]}")
         title, message = await self._build_message_from_template(db, "print_start", variables)
-        await self._send_to_providers(providers, title, message, db, "print_start", printer_id, printer_name)
+        await self._send_to_providers(
+            providers, title, message, db, "print_start", printer_id, printer_name, image_data=image_data
+        )
 
     async def on_print_complete(
         self,
@@ -690,11 +718,16 @@ class NotificationService:
             if archive_data.get("finish_photo_url"):
                 variables["finish_photo_url"] = archive_data["finish_photo_url"]
 
-        logger.info(f"on_print_complete variables: {variables}, archive_data: {archive_data}")
+        # Extract image data for providers that support attachments (e.g. Pushover)
+        image_data = None
+        if archive_data:
+            image_data = archive_data.get("image_data")
 
         logger.info(f"Found {len(providers)} providers for {event_field}: {[p.name for p in providers]}")
         title, message = await self._build_message_from_template(db, event_type, variables)
-        await self._send_to_providers(providers, title, message, db, event_type, printer_id, printer_name)
+        await self._send_to_providers(
+            providers, title, message, db, event_type, printer_id, printer_name, image_data=image_data
+        )
 
     async def on_print_progress(
         self,
@@ -704,6 +737,7 @@ class NotificationService:
         progress: int,
         db: AsyncSession,
         remaining_time: int | None = None,
+        image_data: bytes | None = None,
     ):
         """Handle print progress milestone (25%, 50%, 75%)."""
         providers = await self._get_providers_for_event(db, "on_print_progress", printer_id)
@@ -718,7 +752,9 @@ class NotificationService:
         }
 
         title, message = await self._build_message_from_template(db, "print_progress", variables)
-        await self._send_to_providers(providers, title, message, db, "print_progress", printer_id, printer_name)
+        await self._send_to_providers(
+            providers, title, message, db, "print_progress", printer_id, printer_name, image_data=image_data
+        )
 
     async def on_printer_offline(self, printer_id: int, printer_name: str, db: AsyncSession):
         """Handle printer offline event."""
@@ -738,6 +774,7 @@ class NotificationService:
         error_type: str,
         db: AsyncSession,
         error_detail: str | None = None,
+        image_data: bytes | None = None,
     ):
         """Handle printer error event (AMS issues, etc.)."""
         providers = await self._get_providers_for_event(db, "on_printer_error", printer_id)
@@ -751,7 +788,9 @@ class NotificationService:
         }
 
         title, message = await self._build_message_from_template(db, "printer_error", variables)
-        await self._send_to_providers(providers, title, message, db, "printer_error", printer_id, printer_name)
+        await self._send_to_providers(
+            providers, title, message, db, "printer_error", printer_id, printer_name, image_data=image_data
+        )
 
     async def on_plate_not_empty(
         self,

+ 120 - 28
backend/app/services/print_scheduler.py

@@ -3,11 +3,11 @@
 import asyncio
 import json
 import logging
-import xml.etree.ElementTree as ET
 import zipfile
-from datetime import datetime
+from datetime import datetime, timedelta
 from pathlib import Path
 
+import defusedxml.ElementTree as ET
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
@@ -76,6 +76,18 @@ class PrintScheduler:
                 if item.scheduled_time and item.scheduled_time > datetime.utcnow():
                     continue
 
+                # Safety: Skip stale items (older than 24 hours) to prevent phantom reprints
+                # This protects against items that got stuck in "pending" status due to
+                # crashes/restarts after the print already started
+                stale_threshold = timedelta(hours=24)
+                if item.created_at and datetime.utcnow() - item.created_at.replace(tzinfo=None) > stale_threshold:
+                    logger.warning(f"Queue item {item.id} is stale (created {item.created_at}), marking as expired")
+                    item.status = "expired"
+                    item.error_message = "Queue item expired - older than 24 hours"
+                    item.completed_at = datetime.utcnow()
+                    await db.commit()
+                    continue
+
                 # Skip items that require manual start
                 if item.manual_start:
                     continue
@@ -434,6 +446,8 @@ class PrintScheduler:
                                 filament_id = filament_elem.get("id")
                                 filament_type = filament_elem.get("type", "")
                                 filament_color = filament_elem.get("color", "")
+                                # tray_info_idx identifies the specific spool selected when slicing
+                                tray_info_idx = filament_elem.get("tray_info_idx", "")
                                 used_g = filament_elem.get("used_g", "0")
                                 try:
                                     used_grams = float(used_g)
@@ -443,6 +457,7 @@ class PrintScheduler:
                                                 "slot_id": int(filament_id),
                                                 "type": filament_type,
                                                 "color": filament_color,
+                                                "tray_info_idx": tray_info_idx,
                                                 "used_grams": round(used_grams, 1),
                                             }
                                         )
@@ -455,6 +470,8 @@ class PrintScheduler:
                         filament_id = filament_elem.get("id")
                         filament_type = filament_elem.get("type", "")
                         filament_color = filament_elem.get("color", "")
+                        # tray_info_idx identifies the specific spool selected when slicing
+                        tray_info_idx = filament_elem.get("tray_info_idx", "")
                         used_g = filament_elem.get("used_g", "0")
                         try:
                             used_grams = float(used_g)
@@ -464,6 +481,7 @@ class PrintScheduler:
                                         "slot_id": int(filament_id),
                                         "type": filament_type,
                                         "color": filament_color,
+                                        "tray_info_idx": tray_info_idx,
                                         "used_grams": round(used_grams, 1),
                                     }
                                 )
@@ -500,6 +518,8 @@ class PrintScheduler:
                 if tray_type:
                     tray_id = tray.get("id", 0)
                     tray_color = tray.get("tray_color", "")
+                    # tray_info_idx identifies the specific spool (e.g., "GFA00", "P4d64437")
+                    tray_info_idx = tray.get("tray_info_idx", "")
                     # Normalize color: remove alpha, add hash
                     color = self._normalize_color(tray_color)
                     # Calculate global tray ID
@@ -509,6 +529,7 @@ class PrintScheduler:
                         {
                             "type": tray_type,
                             "color": color,
+                            "tray_info_idx": tray_info_idx,
                             "ams_id": ams_id,
                             "tray_id": tray_id,
                             "is_ht": is_ht,
@@ -525,6 +546,7 @@ class PrintScheduler:
                 {
                     "type": vt_tray["tray_type"],
                     "color": color,
+                    "tray_info_idx": vt_tray.get("tray_info_idx", ""),
                     "ams_id": -1,
                     "tray_id": 0,
                     "is_ht": False,
@@ -569,11 +591,17 @@ class PrintScheduler:
     def _match_filaments_to_slots(self, required: list[dict], loaded: list[dict]) -> list[int] | None:
         """Match required filaments to loaded filaments and build AMS mapping.
 
-        Priority: exact color match > similar color match > type-only match
+        Priority: unique tray_info_idx match > exact color match > similar color match > type-only match
+
+        The tray_info_idx is a filament type identifier stored in the 3MF file when the user
+        slices (e.g., "GFA00" for generic PLA, "P4d64437" for custom presets). If the same
+        tray_info_idx appears in only ONE available tray, we use that tray. If multiple trays
+        have the same tray_info_idx (e.g., two spools of generic PLA), we fall back to color
+        matching among those trays.
 
         Args:
-            required: List of required filaments with slot_id, type, color
-            loaded: List of loaded filaments with type, color, global_tray_id
+            required: List of required filaments with slot_id, type, color, tray_info_idx
+            loaded: List of loaded filaments with type, color, tray_info_idx, global_tray_id
 
         Returns:
             AMS mapping array (position = slot_id - 1, value = global_tray_id or -1)
@@ -588,31 +616,64 @@ class PrintScheduler:
         for req in required:
             req_type = (req.get("type") or "").upper()
             req_color = req.get("color", "")
+            req_tray_info_idx = req.get("tray_info_idx", "")
 
-            # Find best match: exact color > similar color > type-only
+            # Find best match: unique tray_info_idx > exact color > similar color > type-only
+            idx_match = None
             exact_match = None
             similar_match = None
             type_only_match = None
 
-            for f in loaded:
-                if f["global_tray_id"] in used_tray_ids:
-                    continue
-                f_type = (f.get("type") or "").upper()
-                if f_type != req_type:
-                    continue
+            # Get available trays (not already used)
+            available = [f for f in loaded if f["global_tray_id"] not in used_tray_ids]
+
+            # 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:
                 used_tray_ids.add(match["global_tray_id"])
                 comparisons.append({"slot_id": req.get("slot_id", 0), "global_tray_id": match["global_tray_id"]})
@@ -809,6 +870,28 @@ class PrintScheduler:
                 logger.error(f"Queue item {item.id}: Archive {item.archive_id} not found")
                 await self._power_off_if_needed(db, item)
                 return
+
+            # Safety: Check if this archive was printed recently (within 4 hours)
+            # This prevents phantom reprints if a queue item got stuck in "pending"
+            # after its print already started due to a crash/restart
+            if archive.status == "completed" and archive.completed_at:
+                completed_at = (
+                    archive.completed_at.replace(tzinfo=None) if archive.completed_at.tzinfo else archive.completed_at
+                )
+                time_since_completed = datetime.utcnow() - completed_at
+                if time_since_completed < timedelta(hours=4):
+                    logger.warning(
+                        f"Queue item {item.id}: Archive {item.archive_id} was already printed "
+                        f"{time_since_completed.total_seconds() / 3600:.1f} hours ago, skipping to prevent duplicate"
+                    )
+                    item.status = "skipped"
+                    item.error_message = (
+                        f"Archive was already printed {time_since_completed.total_seconds() / 3600:.1f} hours ago"
+                    )
+                    item.completed_at = datetime.utcnow()
+                    await db.commit()
+                    return
+
             file_path = settings.base_dir / archive.file_path
             filename = archive.filename
 
@@ -953,6 +1036,17 @@ class PrintScheduler:
             except json.JSONDecodeError:
                 logger.warning(f"Queue item {item.id}: Invalid AMS mapping JSON, ignoring")
 
+        # IMPORTANT: Set status to "printing" BEFORE sending the print command.
+        # This prevents phantom reprints if the backend crashes/restarts after the
+        # print command is sent but before the status update is committed.
+        # If we crash after this commit but before start_print(), the item will be
+        # in "printing" status without actually printing - but that's safer than
+        # accidentally reprinting the same file hours later.
+        item.status = "printing"
+        item.started_at = datetime.utcnow()
+        await db.commit()
+        logger.info(f"Queue item {item.id}: Status set to 'printing', sending print command...")
+
         # Start the print with AMS mapping, plate_id and print options
         started = printer_manager.start_print(
             item.printer_id,
@@ -968,10 +1062,7 @@ class PrintScheduler:
         )
 
         if started:
-            item.status = "printing"
-            item.started_at = datetime.utcnow()
-            await db.commit()
-            logger.info(f"Queue item {item.id}: Print started - {filename}")
+            logger.info(f"Queue item {item.id}: Print started successfully - {filename}")
 
             # Get estimated time for notification
             estimated_time = None
@@ -1003,6 +1094,7 @@ class PrintScheduler:
             except Exception:
                 pass  # Don't fail if MQTT fails
         else:
+            # Print command failed - revert status
             item.status = "failed"
             item.error_message = "Failed to send print command to printer"
             item.completed_at = datetime.utcnow()

+ 2 - 2
backend/app/services/spoolman.py

@@ -2,7 +2,7 @@
 
 import logging
 from dataclasses import dataclass
-from datetime import UTC, datetime
+from datetime import datetime, timezone
 
 import httpx
 
@@ -355,7 +355,7 @@ class SpoolmanClient:
                 data["extra"] = extra
 
             # Always update last_used
-            data["last_used"] = datetime.now(UTC).isoformat()
+            data["last_used"] = datetime.now(timezone.utc).isoformat()
 
             client = await self._get_client()
             response = await client.patch(f"{self.api_url}/spool/{spool_id}", json=data)

+ 4 - 4
backend/app/services/virtual_printer/certificate.py

@@ -10,7 +10,7 @@ This allows users to add the CA to their slicer's trust store once.
 
 import logging
 import socket
-from datetime import UTC, datetime, timedelta
+from datetime import datetime, timedelta, timezone
 from ipaddress import IPv4Address
 from pathlib import Path
 
@@ -89,7 +89,7 @@ class CertificateService:
             ca_cert = x509.load_pem_x509_certificate(ca_cert_pem)
 
             # Check if CA is expired or about to expire
-            now = datetime.now(UTC)
+            now = datetime.now(timezone.utc)
             days_remaining = (ca_cert.not_valid_after_utc - now).days
             if days_remaining < CA_EXPIRY_THRESHOLD_DAYS:
                 logger.warning(f"CA certificate expires in {days_remaining} days, will regenerate")
@@ -160,7 +160,7 @@ class CertificateService:
             ]
         )
 
-        now = datetime.now(UTC)
+        now = datetime.now(timezone.utc)
 
         ca_cert = (
             x509.CertificateBuilder()
@@ -227,7 +227,7 @@ class CertificateService:
         # Issuer is the CA
         issuer = ca_cert.subject
 
-        now = datetime.now(UTC)
+        now = datetime.now(timezone.utc)
         local_ip = _get_local_ip()
         logger.info(f"Generating printer certificate with CN={self.serial}, local IP: {local_ip}")
 

+ 118 - 21
backend/app/services/virtual_printer/manager.py

@@ -10,14 +10,14 @@ Supports multiple modes:
 import asyncio
 import logging
 from collections.abc import Callable
-from datetime import UTC, datetime
+from datetime import datetime, timezone
 from pathlib import Path
 
 from backend.app.core.config import settings as app_settings
 from backend.app.services.virtual_printer.certificate import CertificateService
 from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPServer
 from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer
-from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
+from backend.app.services.virtual_printer.ssdp_server import SSDPProxy, VirtualPrinterSSDPServer
 from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager
 
 logger = logging.getLogger(__name__)
@@ -93,9 +93,11 @@ class VirtualPrinterManager:
         self._model = DEFAULT_VIRTUAL_PRINTER_MODEL
         self._target_printer_ip = ""  # For proxy mode
         self._target_printer_serial = ""  # For proxy mode (real printer's serial)
+        self._remote_interface_ip = ""  # For proxy mode SSDP (LAN B - slicer network)
 
         # Service instances
         self._ssdp: VirtualPrinterSSDPServer | None = None
+        self._ssdp_proxy: SSDPProxy | None = None
         self._ftp: VirtualPrinterFTPServer | None = None
         self._mqtt: SimpleMQTTServer | None = None
         self._proxy: SlicerProxyManager | None = None  # For proxy mode
@@ -108,12 +110,54 @@ class VirtualPrinterManager:
         self._upload_dir = self._base_dir / "uploads"
         self._cert_dir = self._base_dir / "certs"
 
+        # Create directories early to avoid permission issues later
+        # If running in Docker, these need to be on a writable volume
+        self._ensure_directories()
+
         # Certificate service
         self._cert_service = CertificateService(self._cert_dir)
 
         # Track pending uploads for MQTT correlation
         self._pending_files: dict[str, Path] = {}
 
+    def _ensure_directories(self) -> None:
+        """Create and verify virtual printer directories are writable.
+
+        Creates all required directories at startup to catch permission
+        issues early rather than when the user tries to enable features.
+        """
+        dirs_to_create = [
+            self._base_dir,
+            self._upload_dir,
+            self._upload_dir / "cache",
+            self._cert_dir,
+        ]
+
+        logger.info(f"Checking virtual printer directories in {self._base_dir}")
+
+        for dir_path in dirs_to_create:
+            try:
+                dir_path.mkdir(parents=True, exist_ok=True)
+            except PermissionError:
+                logger.error(
+                    f"Cannot create directory {dir_path}: Permission denied. "
+                    f"For Docker: ensure the data volume is writable by the container user. "
+                    f"For bare metal: run 'sudo chown -R $(whoami) {self._base_dir}'"
+                )
+                continue
+
+            # Verify directory is writable by attempting to create a test file
+            test_file = dir_path / ".write_test"
+            try:
+                test_file.touch()
+                test_file.unlink(missing_ok=True)
+            except PermissionError:
+                logger.error(
+                    f"Directory {dir_path} exists but is not writable. "
+                    f"For Docker: ensure the data volume is writable by the container user (uid/gid). "
+                    f"For bare metal: run 'sudo chown -R $(whoami) {self._base_dir}'"
+                )
+
     def _get_serial_for_model(self, model: str) -> str:
         """Get appropriate serial number for the given model.
 
@@ -157,6 +201,7 @@ class VirtualPrinterManager:
         model: str = "",
         target_printer_ip: str = "",
         target_printer_serial: str = "",
+        remote_interface_ip: str = "",
     ) -> None:
         """Configure and start/stop virtual printer.
 
@@ -167,6 +212,7 @@ class VirtualPrinterManager:
             model: SSDP model code (e.g., 'BL-P001' for X1C)
             target_printer_ip: Target printer IP for proxy mode
             target_printer_serial: Target printer serial for proxy mode
+            remote_interface_ip: IP of interface on slicer network (LAN B) for SSDP proxy
         """
         # Proxy mode has different requirements
         if mode == "proxy":
@@ -183,12 +229,14 @@ class VirtualPrinterManager:
         mode_changed = mode != self._mode
         target_changed = target_printer_ip != self._target_printer_ip
         serial_changed = target_printer_serial != self._target_printer_serial
+        remote_iface_changed = remote_interface_ip != self._remote_interface_ip
         old_mode = self._mode
 
         logger.debug(
             f"configure() called: enabled={enabled}, self._enabled={self._enabled}, "
             f"mode={mode}, old_mode={old_mode}, model={model}, new_model={new_model}, "
-            f"target_printer_ip={target_printer_ip}, target_printer_serial={target_printer_serial}"
+            f"target_printer_ip={target_printer_ip}, target_printer_serial={target_printer_serial}, "
+            f"remote_interface_ip={remote_interface_ip}"
         )
 
         self._access_code = access_code
@@ -196,8 +244,13 @@ class VirtualPrinterManager:
         self._model = new_model
         self._target_printer_ip = target_printer_ip
         self._target_printer_serial = target_printer_serial
+        self._remote_interface_ip = remote_interface_ip
 
-        needs_restart = model_changed or mode_changed or (mode == "proxy" and (target_changed or serial_changed))
+        needs_restart = (
+            model_changed
+            or mode_changed
+            or (mode == "proxy" and (target_changed or serial_changed or remote_iface_changed))
+        )
 
         if enabled and not self._enabled:
             logger.info("Starting virtual printer (was disabled)")
@@ -247,13 +300,6 @@ class VirtualPrinterManager:
         cert_path, key_path = self._cert_service.generate_certificates()
         logger.info(f"Generated certificate for proxy serial: {proxy_serial}")
 
-        # Initialize SSDP for local discovery using the real printer's serial
-        self._ssdp = VirtualPrinterSSDPServer(
-            name=f"{self.PRINTER_NAME} (Proxy)",
-            serial=proxy_serial,
-            model=self._model,
-        )
-
         # Initialize TLS proxy with our certificates
         self._proxy = SlicerProxyManager(
             target_host=self._target_printer_ip,
@@ -269,21 +315,68 @@ class VirtualPrinterManager:
             except Exception as e:
                 logger.error(f"Virtual printer {name} failed: {e}")
 
-        self._tasks = [
-            asyncio.create_task(
-                run_with_logging(self._ssdp.start(), "SSDP"),
-                name="virtual_printer_ssdp",
-            ),
+        self._tasks = []
+
+        # SSDP setup: use SSDPProxy if remote interface is configured
+        # Local interface is auto-detected from target printer IP
+        if self._remote_interface_ip:
+            # Auto-detect local interface based on target printer IP
+            from backend.app.services.network_utils import find_interface_for_ip
+
+            local_iface = find_interface_for_ip(self._target_printer_ip)
+            if local_iface:
+                local_interface_ip = local_iface["ip"]
+                logger.info(
+                    f"SSDP proxy mode: LAN A ({local_interface_ip}, auto-detected) -> LAN B ({self._remote_interface_ip})"
+                )
+                self._ssdp_proxy = SSDPProxy(
+                    local_interface_ip=local_interface_ip,
+                    remote_interface_ip=self._remote_interface_ip,
+                    target_printer_ip=self._target_printer_ip,
+                )
+                self._tasks.append(
+                    asyncio.create_task(
+                        run_with_logging(self._ssdp_proxy.start(), "SSDP Proxy"),
+                        name="virtual_printer_ssdp_proxy",
+                    )
+                )
+            else:
+                logger.warning(
+                    f"Could not auto-detect local interface for printer {self._target_printer_ip}, "
+                    "falling back to single-interface SSDP"
+                )
+                self._start_fallback_ssdp(proxy_serial, run_with_logging)
+        else:
+            # Single interface: broadcast SSDP on same network (fallback)
+            self._start_fallback_ssdp(proxy_serial, run_with_logging)
+
+        # Add TLS proxy task
+        self._tasks.append(
             asyncio.create_task(
                 run_with_logging(self._proxy.start(), "Proxy"),
                 name="virtual_printer_proxy",
-            ),
-        ]
+            )
+        )
 
         logger.info(
             f"Virtual printer proxy started: "
-            f"FTP 0.0.0.0:{SlicerProxyManager.LOCAL_FTP_PORT} → {self._target_printer_ip}:{SlicerProxyManager.PRINTER_FTP_PORT}, "
-            f"MQTT 0.0.0.0:{SlicerProxyManager.LOCAL_MQTT_PORT} → {self._target_printer_ip}:{SlicerProxyManager.PRINTER_MQTT_PORT}"
+            f"FTP 0.0.0.0:{SlicerProxyManager.LOCAL_FTP_PORT} -> {self._target_printer_ip}:{SlicerProxyManager.PRINTER_FTP_PORT}, "
+            f"MQTT 0.0.0.0:{SlicerProxyManager.LOCAL_MQTT_PORT} -> {self._target_printer_ip}:{SlicerProxyManager.PRINTER_MQTT_PORT}"
+        )
+
+    def _start_fallback_ssdp(self, proxy_serial: str, run_with_logging) -> None:
+        """Start single-interface SSDP server as fallback."""
+        logger.info("SSDP broadcast mode (single interface)")
+        self._ssdp = VirtualPrinterSSDPServer(
+            name=f"{self.PRINTER_NAME} (Proxy)",
+            serial=proxy_serial,
+            model=self._model,
+        )
+        self._tasks.append(
+            asyncio.create_task(
+                run_with_logging(self._ssdp.start(), "SSDP"),
+                name="virtual_printer_ssdp",
+            )
         )
 
     async def _start_server_mode(self) -> None:
@@ -361,6 +454,10 @@ class VirtualPrinterManager:
             await self._ssdp.stop()
             self._ssdp = None
 
+        if self._ssdp_proxy:
+            await self._ssdp_proxy.stop()
+            self._ssdp_proxy = None
+
         if self._proxy:
             await self._proxy.stop()
             self._proxy = None
@@ -501,7 +598,7 @@ class VirtualPrinterManager:
                     file_size=file_path.stat().st_size,
                     source_ip=source_ip,
                     status="pending",
-                    uploaded_at=datetime.now(UTC),
+                    uploaded_at=datetime.now(timezone.utc),
                 )
                 db.add(pending)
                 await db.commit()

+ 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
 announcements so the virtual printer appears as a discoverable Bambu printer.
+
+Also provides SSDP proxy functionality for proxy mode, where Bambuddy sits
+between two networks and re-broadcasts printer SSDP from LAN A to LAN B.
 """
 
 import asyncio
 import logging
+import re
 import socket
 import struct
-from datetime import datetime
 
 logger = logging.getLogger(__name__)
 
-# SSDP multicast address - Bambu uses port 2021
-SSDP_ADDR = "239.255.255.250"
+# SSDP addresses - Bambu uses port 2021
+# Real Bambu printers broadcast to 255.255.255.255, not multicast to 239.255.255.250
+SSDP_MULTICAST_ADDR = "239.255.255.250"
+SSDP_BROADCAST_ADDR = "255.255.255.255"
 SSDP_PORT = 2021
 
 # Bambu service target
@@ -60,44 +65,49 @@ class VirtualPrinterSSDPServer:
             return "127.0.0.1"
 
     def _build_notify_message(self) -> bytes:
-        """Build SSDP NOTIFY message for periodic announcements."""
+        """Build SSDP NOTIFY message for periodic announcements.
+
+        Format matches real Bambu printer SSDP broadcasts observed on the network.
+        Real printers use Host: 239.255.255.250:1990 (port 1990 in header).
+        """
         ip = self._get_local_ip()
-        # Based on: https://gist.github.com/Alex-Schaefer/72a9e2491a42da2ef99fb87601955cc3
+        # Match exact format of real Bambu printers (captured via tcpdump)
         # Key: DevBind.bambu.com: free - tells slicer printer is NOT cloud-bound
         message = (
             "NOTIFY * HTTP/1.1\r\n"
-            f"HOST: {SSDP_ADDR}:{SSDP_PORT}\r\n"
-            "Server: Buildroot/2018.02-rc3 UPnP/1.0 ssdpd/1.8\r\n"
-            "Cache-Control: max-age=1800\r\n"
+            f"Host: {SSDP_MULTICAST_ADDR}:1990\r\n"
+            "Server: UPnP/1.0\r\n"
             f"Location: {ip}\r\n"
             f"NT: {BAMBU_SEARCH_TARGET}\r\n"
             "NTS: ssdp:alive\r\n"
-            "EXT:\r\n"
             f"USN: {self.serial}\r\n"
+            "Cache-Control: max-age=1800\r\n"
             f"DevModel.bambu.com: {self.model}\r\n"
             f"DevName.bambu.com: {self.name}\r\n"
             "DevSignal.bambu.com: -44\r\n"
             "DevConnect.bambu.com: lan\r\n"
             "DevBind.bambu.com: free\r\n"
             "Devseclink.bambu.com: secure\r\n"
+            "DevInf.bambu.com: eth0\r\n"
             "DevVersion.bambu.com: 01.07.00.00\r\n"
+            "DevCap.bambu.com: 1\r\n"
             "\r\n"
         )
         return message.encode()
 
     def _build_response_message(self) -> bytes:
-        """Build SSDP response message for M-SEARCH requests."""
+        """Build SSDP response message for M-SEARCH requests.
+
+        Format matches real Bambu printer SSDP responses.
+        """
         ip = self._get_local_ip()
-        # Based on: https://gist.github.com/Alex-Schaefer/72a9e2491a42da2ef99fb87601955cc3
+        # Match format of real Bambu printers
         # Key: DevBind.bambu.com: free - tells slicer printer is NOT cloud-bound
-        # Added: Devseclink, DevVersion, DevCap for better compatibility
         message = (
             "HTTP/1.1 200 OK\r\n"
-            "Server: Buildroot/2018.02-rc3 UPnP/1.0 ssdpd/1.8\r\n"
-            f"Date: {datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT')}\r\n"
+            "Server: UPnP/1.0\r\n"
             f"Location: {ip}\r\n"
             f"ST: {BAMBU_SEARCH_TARGET}\r\n"
-            "EXT:\r\n"
             f"USN: {self.serial}\r\n"
             "Cache-Control: max-age=1800\r\n"
             f"DevModel.bambu.com: {self.model}\r\n"
@@ -106,7 +116,9 @@ class VirtualPrinterSSDPServer:
             "DevConnect.bambu.com: lan\r\n"
             "DevBind.bambu.com: free\r\n"
             "Devseclink.bambu.com: secure\r\n"
+            "DevInf.bambu.com: eth0\r\n"
             "DevVersion.bambu.com: 01.07.00.00\r\n"
+            "DevCap.bambu.com: 1\r\n"
             "\r\n"
         )
         return message.encode()
@@ -137,7 +149,7 @@ class VirtualPrinterSSDPServer:
             self._socket.bind(("", SSDP_PORT))
 
             # Join multicast group
-            mreq = struct.pack("4sl", socket.inet_aton(SSDP_ADDR), socket.INADDR_ANY)
+            mreq = struct.pack("4sl", socket.inet_aton(SSDP_MULTICAST_ADDR), socket.INADDR_ANY)
             self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
 
             # Enable broadcast
@@ -212,13 +224,14 @@ class VirtualPrinterSSDPServer:
             self._socket = None
 
     async def _send_notify(self) -> None:
-        """Send SSDP NOTIFY message."""
+        """Send SSDP NOTIFY message via broadcast (like real Bambu printers)."""
         if not self._socket:
             return
 
         try:
             msg = self._build_notify_message()
-            self._socket.sendto(msg, (SSDP_ADDR, SSDP_PORT))
+            # Real Bambu printers broadcast to 255.255.255.255, not multicast
+            self._socket.sendto(msg, (SSDP_BROADCAST_ADDR, SSDP_PORT))
             logger.debug(f"Sent SSDP NOTIFY for {self.name}")
         except Exception as e:
             logger.debug(f"Failed to send NOTIFY: {e}")
@@ -230,7 +243,7 @@ class VirtualPrinterSSDPServer:
 
         message = (
             "NOTIFY * HTTP/1.1\r\n"
-            f"HOST: {SSDP_ADDR}:{SSDP_PORT}\r\n"
+            f"Host: {SSDP_MULTICAST_ADDR}:1990\r\n"
             f"NT: {BAMBU_SEARCH_TARGET}\r\n"
             "NTS: ssdp:byebye\r\n"
             f"USN: {self.serial}\r\n"
@@ -238,7 +251,7 @@ class VirtualPrinterSSDPServer:
         )
 
         try:
-            self._socket.sendto(message.encode(), (SSDP_ADDR, SSDP_PORT))
+            self._socket.sendto(message.encode(), (SSDP_BROADCAST_ADDR, SSDP_PORT))
             logger.debug("Sent SSDP byebye")
         except Exception:
             pass
@@ -268,3 +281,215 @@ class VirtualPrinterSSDPServer:
                 logger.info(f"Sent SSDP response to {addr[0]} for virtual printer '{self.name}'")
             except Exception as e:
                 logger.debug(f"Failed to send SSDP response: {e}")
+
+
+class SSDPProxy:
+    """SSDP proxy that re-broadcasts printer discovery from one network to another.
+
+    Listens for SSDP broadcasts from a real printer on the local interface (LAN A),
+    then re-broadcasts them on the remote interface (LAN B) with the Location
+    header changed to point to Bambuddy's IP on LAN B.
+
+    This allows Bambu Studio on LAN B to discover the printer via Bambuddy.
+    """
+
+    def __init__(
+        self,
+        local_interface_ip: str,
+        remote_interface_ip: str,
+        target_printer_ip: str,
+    ):
+        """Initialize the SSDP proxy.
+
+        Args:
+            local_interface_ip: IP of interface on printer's network (LAN A)
+            remote_interface_ip: IP of interface on slicer's network (LAN B)
+            target_printer_ip: IP of the real printer to proxy SSDP for
+        """
+        self.local_interface_ip = local_interface_ip
+        self.remote_interface_ip = remote_interface_ip
+        self.target_printer_ip = target_printer_ip
+        self._running = False
+        self._local_socket: socket.socket | None = None
+        self._remote_socket: socket.socket | None = None
+        self._last_printer_ssdp: bytes | None = None
+        self._printer_info: dict[str, str] = {}
+
+    def _parse_ssdp_message(self, data: bytes) -> dict[str, str]:
+        """Parse SSDP message into header dict."""
+        headers = {}
+        try:
+            text = data.decode("utf-8", errors="ignore")
+            for line in text.split("\r\n"):
+                if ":" in line:
+                    key, value = line.split(":", 1)
+                    headers[key.strip().lower()] = value.strip()
+        except Exception:
+            pass
+        return headers
+
+    def _rewrite_ssdp_location(self, data: bytes) -> bytes:
+        """Rewrite SSDP message with Bambuddy's remote IP as Location."""
+        try:
+            text = data.decode("utf-8", errors="ignore")
+            original = text
+            # Replace Location header with our remote interface IP
+            text = re.sub(
+                r"(Location:\s*)[\d.]+",
+                f"\\g<1>{self.remote_interface_ip}",
+                text,
+                flags=re.IGNORECASE,
+            )
+            if text != original:
+                logger.debug(f"Rewrote SSDP Location to {self.remote_interface_ip}")
+                logger.debug(f"Rewritten SSDP packet:\n{text}")
+            else:
+                logger.warning(f"SSDP Location rewrite had no effect. Packet:\n{original}")
+            return text.encode("utf-8")
+        except Exception as e:
+            logger.error(f"Failed to rewrite SSDP: {e}")
+            return data
+
+    async def start(self) -> None:
+        """Start the SSDP proxy."""
+        if self._running:
+            return
+
+        logger.info(
+            f"Starting SSDP proxy: listening on {self.local_interface_ip} (LAN A), "
+            f"broadcasting on {self.remote_interface_ip} (LAN B), "
+            f"proxying printer {self.target_printer_ip}"
+        )
+        self._running = True
+
+        try:
+            # Create socket for listening on LAN A (printer network)
+            # Bind to 0.0.0.0 to receive broadcast packets (255.255.255.255)
+            # We filter by source IP in the handler
+            self._local_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
+            self._local_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+            try:
+                self._local_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
+            except (AttributeError, OSError):
+                pass
+            self._local_socket.setblocking(False)
+            # Bind to all interfaces to receive broadcasts
+            self._local_socket.bind(("", SSDP_PORT))
+
+            # Join multicast group on local interface (for multicast SSDP if used)
+            mreq = struct.pack(
+                "4s4s",
+                socket.inet_aton(SSDP_MULTICAST_ADDR),
+                socket.inet_aton(self.local_interface_ip),
+            )
+            self._local_socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
+            self._local_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+
+            # Create socket for broadcasting on LAN B (slicer network)
+            self._remote_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
+            self._remote_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+            try:
+                self._remote_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
+            except (AttributeError, OSError):
+                pass
+            self._remote_socket.setblocking(False)
+            # Bind to remote interface
+            self._remote_socket.bind((self.remote_interface_ip, 0))
+            self._remote_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+
+            logger.info(f"SSDP proxy listening on 0.0.0.0:{SSDP_PORT} (filtering for printer {self.target_printer_ip})")
+            logger.info(f"SSDP proxy will broadcast on {self.remote_interface_ip}")
+
+            # Main loop
+            last_broadcast = 0.0
+            broadcast_interval = 30.0  # Re-broadcast every 30 seconds
+
+            while self._running:
+                # Listen for SSDP from printer on LAN A
+                try:
+                    data, addr = self._local_socket.recvfrom(4096)
+                    await self._handle_local_packet(data, addr)
+                except BlockingIOError:
+                    pass
+                except Exception as e:
+                    if self._running:
+                        logger.debug(f"SSDP proxy receive error: {e}")
+
+                # Listen for M-SEARCH from slicer on LAN B (via remote socket would need separate bind)
+                # For now, we periodically re-broadcast cached printer SSDP
+                now = asyncio.get_event_loop().time()
+                if self._last_printer_ssdp and now - last_broadcast >= broadcast_interval:
+                    await self._broadcast_to_remote()
+                    last_broadcast = now
+
+                await asyncio.sleep(0.1)
+
+        except OSError as e:
+            logger.error(f"SSDP proxy error: {e}")
+        except asyncio.CancelledError:
+            logger.debug("SSDP proxy cancelled")
+        except Exception as e:
+            logger.error(f"SSDP proxy error: {e}")
+        finally:
+            await self._cleanup()
+
+    async def stop(self) -> None:
+        """Stop the SSDP proxy."""
+        logger.info("Stopping SSDP proxy")
+        self._running = False
+        await self._cleanup()
+
+    async def _cleanup(self) -> None:
+        """Clean up resources."""
+        for sock in [self._local_socket, self._remote_socket]:
+            if sock:
+                try:
+                    sock.close()
+                except Exception:
+                    pass
+        self._local_socket = None
+        self._remote_socket = None
+
+    async def _handle_local_packet(self, data: bytes, addr: tuple[str, int]) -> None:
+        """Handle SSDP packet received on local interface (LAN A)."""
+        sender_ip = addr[0]
+
+        # Only process packets from the target printer
+        if sender_ip != self.target_printer_ip:
+            return
+
+        # Check if it's a NOTIFY message
+        if b"NOTIFY" not in data and b"HTTP/1.1 200" not in data:
+            return
+
+        # Check if it's a Bambu printer SSDP
+        if b"bambulab-com:device:3dprinter" not in data:
+            return
+
+        # Parse and store printer info
+        headers = self._parse_ssdp_message(data)
+        if headers:
+            self._printer_info = headers
+            logger.debug(f"Received SSDP from printer {sender_ip}: {headers.get('devname.bambu.com', 'unknown')}")
+
+        # Store and immediately broadcast
+        self._last_printer_ssdp = data
+        await self._broadcast_to_remote()
+
+    async def _broadcast_to_remote(self) -> None:
+        """Broadcast cached printer SSDP on remote interface (LAN B)."""
+        if not self._remote_socket or not self._last_printer_ssdp:
+            return
+
+        try:
+            # Rewrite Location to point to Bambuddy's remote interface
+            rewritten = self._rewrite_ssdp_location(self._last_printer_ssdp)
+
+            # Calculate broadcast address for remote network
+            # Use 255.255.255.255 for simplicity (works across subnets)
+            self._remote_socket.sendto(rewritten, (SSDP_BROADCAST_ADDR, SSDP_PORT))
+
+            printer_name = self._printer_info.get("devname.bambu.com", "unknown")
+            logger.debug(f"Broadcast SSDP for '{printer_name}' on LAN B ({self.remote_interface_ip})")
+        except Exception as e:
+            logger.debug(f"Failed to broadcast SSDP on remote: {e}")

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

@@ -289,8 +289,9 @@ class SlicerProxyManager:
     PRINTER_FTP_PORT = 990
     PRINTER_MQTT_PORT = 8883
 
-    # Local listen ports (same as virtual printer)
-    LOCAL_FTP_PORT = 9990
+    # Local listen ports - must match what Bambu Studio expects
+    # Note: Port 990 requires root or CAP_NET_BIND_SERVICE capability
+    LOCAL_FTP_PORT = 990
     LOCAL_MQTT_PORT = 8883
 
     def __init__(

+ 8 - 8
backend/tests/unit/services/test_archive_service.py

@@ -221,7 +221,7 @@ class TestPrintableObjectsExtraction:
 
     def test_extract_printable_objects_from_slice_info(self):
         """Test parsing printable objects from slice_info.config XML."""
-        from xml.etree import ElementTree as ET
+        from defusedxml import ElementTree as ET
 
         # Example slice_info.config content with 4 objects
         slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
@@ -250,7 +250,7 @@ class TestPrintableObjectsExtraction:
 
     def test_extract_printable_objects_empty_plate(self):
         """Test handling plate with no objects."""
-        from xml.etree import ElementTree as ET
+        from defusedxml import ElementTree as ET
 
         slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
         <config>
@@ -272,7 +272,7 @@ class TestPrintableObjectsExtraction:
 
     def test_extract_printable_objects_all_skipped(self):
         """Test handling plate where all objects are skipped."""
-        from xml.etree import ElementTree as ET
+        from defusedxml import ElementTree as ET
 
         slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
         <config>
@@ -299,7 +299,7 @@ class TestThreeMFPlateIndexExtraction:
 
     def test_extract_plate_index_from_slice_info(self):
         """Test parsing plate index from slice_info.config metadata."""
-        from xml.etree import ElementTree as ET
+        from defusedxml import ElementTree as ET
 
         # Single-plate export from plate 5 of a multi-plate project
         slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
@@ -325,7 +325,7 @@ class TestThreeMFPlateIndexExtraction:
 
     def test_extract_plate_index_plate_1(self):
         """Test parsing plate index when it's plate 1."""
-        from xml.etree import ElementTree as ET
+        from defusedxml import ElementTree as ET
 
         slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
         <config>
@@ -402,7 +402,7 @@ class TestThreeMFPlateIndexExtraction:
 
     def test_high_plate_number_extraction(self):
         """Test extracting high plate numbers (e.g., plate 28)."""
-        from xml.etree import ElementTree as ET
+        from defusedxml import ElementTree as ET
 
         slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
         <config>
@@ -433,7 +433,7 @@ class TestMultiPlate3MFParsing:
 
     def test_parse_multiple_plates_from_slice_info(self):
         """Test parsing multiple plates from slice_info.config."""
-        from xml.etree import ElementTree as ET
+        from defusedxml import ElementTree as ET
 
         # Multi-plate 3MF with 3 plates
         slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
@@ -510,7 +510,7 @@ class TestMultiPlate3MFParsing:
 
     def test_filter_filaments_by_plate_id(self):
         """Test filtering filaments for a specific plate."""
-        from xml.etree import ElementTree as ET
+        from defusedxml import ElementTree as ET
 
         slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
         <config>

+ 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):
         """Verify proxy manager has correct port constants."""
-        assert proxy_manager.LOCAL_FTP_PORT == 9990
+        # FTP proxy uses privileged port 990 to match what Bambu Studio expects
+        assert proxy_manager.LOCAL_FTP_PORT == 990
         assert proxy_manager.LOCAL_MQTT_PORT == 8883
         assert proxy_manager.PRINTER_FTP_PORT == 990
         assert proxy_manager.PRINTER_MQTT_PORT == 8883
@@ -482,6 +483,125 @@ class TestSlicerProxyManager:
         assert status["mqtt_connections"] == 0
 
 
+class TestSSDPProxy:
+    """Tests for SSDPProxy (cross-network SSDP relay)."""
+
+    @pytest.fixture
+    def ssdp_proxy(self):
+        """Create an SSDPProxy instance."""
+        from backend.app.services.virtual_printer.ssdp_server import SSDPProxy
+
+        return SSDPProxy(
+            local_interface_ip="192.168.1.100",
+            remote_interface_ip="10.0.0.100",
+            target_printer_ip="192.168.1.50",
+        )
+
+    def test_ssdp_proxy_stores_interface_ips(self, ssdp_proxy):
+        """Verify SSDPProxy stores interface IPs correctly."""
+        assert ssdp_proxy.local_interface_ip == "192.168.1.100"
+        assert ssdp_proxy.remote_interface_ip == "10.0.0.100"
+        assert ssdp_proxy.target_printer_ip == "192.168.1.50"
+
+    def test_rewrite_ssdp_location(self, ssdp_proxy):
+        """Verify SSDP Location header is rewritten to remote interface IP."""
+        original_packet = b"NOTIFY * HTTP/1.1\r\nLocation: 192.168.1.50\r\nDevName.bambu.com: TestPrinter\r\n\r\n"
+
+        rewritten = ssdp_proxy._rewrite_ssdp_location(original_packet)
+
+        # Location should be changed to remote interface IP
+        assert b"Location: 10.0.0.100" in rewritten
+        assert b"Location: 192.168.1.50" not in rewritten
+        # Other headers should be preserved
+        assert b"DevName.bambu.com: TestPrinter" in rewritten
+
+    def test_rewrite_ssdp_location_case_insensitive(self, ssdp_proxy):
+        """Verify SSDP Location rewrite is case insensitive."""
+        original_packet = b"NOTIFY * HTTP/1.1\r\nlocation: 192.168.1.50\r\n\r\n"
+
+        rewritten = ssdp_proxy._rewrite_ssdp_location(original_packet)
+
+        assert b"10.0.0.100" in rewritten
+
+    def test_rewrite_ssdp_location_no_match(self, ssdp_proxy):
+        """Verify packet without Location header is returned unchanged."""
+        original_packet = b"NOTIFY * HTTP/1.1\r\nDevName.bambu.com: Test\r\n\r\n"
+
+        rewritten = ssdp_proxy._rewrite_ssdp_location(original_packet)
+
+        # Should be unchanged (no Location header to rewrite)
+        assert rewritten == original_packet
+
+    def test_parse_ssdp_message(self, ssdp_proxy):
+        """Verify SSDP message parsing extracts headers."""
+        packet = (
+            b"NOTIFY * HTTP/1.1\r\n"
+            b"Location: 192.168.1.50\r\n"
+            b"DevName.bambu.com: TestPrinter\r\n"
+            b"DevModel.bambu.com: BL-P001\r\n"
+            b"\r\n"
+        )
+
+        headers = ssdp_proxy._parse_ssdp_message(packet)
+
+        assert headers["location"] == "192.168.1.50"
+        assert headers["devname.bambu.com"] == "TestPrinter"
+        assert headers["devmodel.bambu.com"] == "BL-P001"
+
+
+class TestVirtualPrinterManagerDirectories:
+    """Tests for VirtualPrinterManager directory management."""
+
+    def test_ensure_directories_creates_subdirs(self, tmp_path):
+        """Verify _ensure_directories creates all required subdirectories."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterManager
+
+        # Create a manager and manually call _ensure_directories with our tmp path
+        manager = VirtualPrinterManager()
+        # Override the paths
+        manager._base_dir = tmp_path / "virtual_printer"
+        manager._upload_dir = manager._base_dir / "uploads"
+        manager._cert_dir = manager._base_dir / "certs"
+
+        # Call the method
+        manager._ensure_directories()
+
+        # All directories should be created
+        assert (tmp_path / "virtual_printer").exists()
+        assert (tmp_path / "virtual_printer" / "uploads").exists()
+        assert (tmp_path / "virtual_printer" / "uploads" / "cache").exists()
+        assert (tmp_path / "virtual_printer" / "certs").exists()
+
+    def test_ensure_directories_handles_permission_error(self, tmp_path, caplog):
+        """Verify _ensure_directories logs error on permission failure."""
+        import logging
+        from unittest.mock import patch
+
+        from backend.app.services.virtual_printer.manager import VirtualPrinterManager
+
+        # Create manager and override paths
+        manager = VirtualPrinterManager()
+        vp_dir = tmp_path / "virtual_printer"
+
+        manager._base_dir = vp_dir
+        manager._upload_dir = vp_dir / "uploads"
+        manager._cert_dir = vp_dir / "certs"
+
+        # Mock mkdir to raise PermissionError (chmod doesn't work as root in Docker)
+        original_mkdir = type(vp_dir).mkdir
+
+        def mock_mkdir(self, *args, **kwargs):
+            if "virtual_printer" in str(self):
+                raise PermissionError("Permission denied")
+            return original_mkdir(self, *args, **kwargs)
+
+        with caplog.at_level(logging.ERROR), patch.object(type(vp_dir), "mkdir", mock_mkdir):
+            # This should log errors but not raise
+            manager._ensure_directories()
+            # Check that error was logged
+            assert "Permission denied" in caplog.text
+
+
 class TestVirtualPrinterManagerProxyMode:
     """Tests for VirtualPrinterManager proxy mode."""
 
@@ -528,7 +648,7 @@ class TestVirtualPrinterManagerProxyMode:
         mock_proxy = MagicMock()
         mock_proxy.get_status.return_value = {
             "running": True,
-            "ftp_port": 9990,
+            "ftp_port": 990,  # Privileged port for Bambu Studio compatibility
             "mqtt_port": 8883,
             "ftp_connections": 1,
             "mqtt_connections": 2,
@@ -541,5 +661,46 @@ class TestVirtualPrinterManagerProxyMode:
         assert status["mode"] == "proxy"
         assert status["target_printer_ip"] == "192.168.1.100"
         assert "proxy" in status
+        assert status["proxy"]["ftp_port"] == 990  # Privileged port for Bambu Studio compatibility
+        assert status["proxy"]["mqtt_port"] == 8883
         assert status["proxy"]["ftp_connections"] == 1
         assert status["proxy"]["mqtt_connections"] == 2
+
+    @pytest.mark.asyncio
+    async def test_configure_proxy_mode_with_remote_interface(self, manager):
+        """Verify proxy mode accepts remote_interface_ip for SSDP proxy."""
+        manager._start = AsyncMock()
+
+        await manager.configure(
+            enabled=True,
+            mode="proxy",
+            target_printer_ip="192.168.1.100",
+            remote_interface_ip="10.0.0.50",
+        )
+
+        assert manager._mode == "proxy"
+        assert manager._target_printer_ip == "192.168.1.100"
+        assert manager._remote_interface_ip == "10.0.0.50"
+
+    @pytest.mark.asyncio
+    async def test_configure_proxy_mode_restarts_on_remote_interface_change(self, manager):
+        """Verify changing remote_interface_ip restarts services."""
+        # Simulate running state
+        manager._enabled = True
+        manager._mode = "proxy"
+        manager._target_printer_ip = "192.168.1.100"
+        manager._remote_interface_ip = "10.0.0.50"
+        manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
+        manager._stop = AsyncMock()
+        manager._start = AsyncMock()
+
+        await manager.configure(
+            enabled=True,
+            mode="proxy",
+            target_printer_ip="192.168.1.100",
+            remote_interface_ip="10.0.0.99",  # Changed
+        )
+
+        # Should have stopped and started
+        manager._stop.assert_called_once()
+        manager._start.assert_called_once()

+ 203 - 0
backend/tests/unit/test_plate_object_extraction.py

@@ -0,0 +1,203 @@
+"""Unit tests for plate object extraction from 3MF model_settings.config."""
+
+import pytest
+from defusedxml import ElementTree as ET
+
+
+class TestPlateObjectExtraction:
+    """Tests for extracting object IDs and names from model_settings.config XML."""
+
+    def test_extract_object_names_from_xml(self):
+        """Verify object names are extracted from model_settings.config XML."""
+        xml_content = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <object id="1">
+                <metadata key="name" value="Cube"/>
+            </object>
+            <object id="2">
+                <metadata key="name" value="Sphere"/>
+            </object>
+            <object id="3">
+                <metadata key="name" value="Cylinder"/>
+            </object>
+        </config>
+        """
+        root = ET.fromstring(xml_content)
+
+        object_names_by_id = {}
+        for obj in root.findall(".//object"):
+            obj_id = obj.get("id")
+            if obj_id:
+                name_meta = obj.find("./metadata[@key='name']")
+                if name_meta is not None:
+                    object_names_by_id[obj_id] = name_meta.get("value", f"Object {obj_id}")
+                else:
+                    object_names_by_id[obj_id] = f"Object {obj_id}"
+
+        assert object_names_by_id == {
+            "1": "Cube",
+            "2": "Sphere",
+            "3": "Cylinder",
+        }
+
+    def test_extract_object_names_missing_name(self):
+        """Verify objects without names get default names."""
+        xml_content = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <object id="1">
+                <metadata key="name" value="Named Object"/>
+            </object>
+            <object id="2">
+                <!-- No name metadata -->
+            </object>
+        </config>
+        """
+        root = ET.fromstring(xml_content)
+
+        object_names_by_id = {}
+        for obj in root.findall(".//object"):
+            obj_id = obj.get("id")
+            if obj_id:
+                name_meta = obj.find("./metadata[@key='name']")
+                if name_meta is not None:
+                    object_names_by_id[obj_id] = name_meta.get("value", f"Object {obj_id}")
+                else:
+                    object_names_by_id[obj_id] = f"Object {obj_id}"
+
+        assert object_names_by_id == {
+            "1": "Named Object",
+            "2": "Object 2",
+        }
+
+    def test_extract_plate_object_associations(self):
+        """Verify plate-to-object associations are extracted correctly."""
+        xml_content = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <plate>
+                <metadata key="plater_id" value="1"/>
+                <model_instance>
+                    <metadata key="object_id" value="1"/>
+                </model_instance>
+                <model_instance>
+                    <metadata key="object_id" value="2"/>
+                </model_instance>
+            </plate>
+            <plate>
+                <metadata key="plater_id" value="2"/>
+                <model_instance>
+                    <metadata key="object_id" value="3"/>
+                </model_instance>
+            </plate>
+        </config>
+        """
+        root = ET.fromstring(xml_content)
+
+        plate_object_ids = {}
+        for plate in root.findall(".//plate"):
+            plate_id = None
+            for meta in plate.findall("metadata"):
+                if meta.get("key") in ("plater_id", "plate_id"):
+                    plate_id = meta.get("value")
+                    break
+
+            if plate_id:
+                object_ids = []
+                for instance in plate.findall(".//model_instance"):
+                    for meta in instance.findall("metadata"):
+                        if meta.get("key") == "object_id":
+                            object_ids.append(meta.get("value"))
+                plate_object_ids[plate_id] = object_ids
+
+        assert plate_object_ids == {
+            "1": ["1", "2"],
+            "2": ["3"],
+        }
+
+    def test_extract_plate_object_associations_empty_plate(self):
+        """Verify empty plates have empty object lists."""
+        xml_content = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <plate>
+                <metadata key="plater_id" value="1"/>
+                <!-- No model_instances -->
+            </plate>
+        </config>
+        """
+        root = ET.fromstring(xml_content)
+
+        plate_object_ids = {}
+        for plate in root.findall(".//plate"):
+            plate_id = None
+            for meta in plate.findall("metadata"):
+                if meta.get("key") in ("plater_id", "plate_id"):
+                    plate_id = meta.get("value")
+                    break
+
+            if plate_id:
+                object_ids = []
+                for instance in plate.findall(".//model_instance"):
+                    for meta in instance.findall("metadata"):
+                        if meta.get("key") == "object_id":
+                            object_ids.append(meta.get("value"))
+                plate_object_ids[plate_id] = object_ids
+
+        assert plate_object_ids == {"1": []}
+
+    def test_object_count_matches_objects_length(self):
+        """Verify object_count equals len(objects)."""
+        objects = ["Cube", "Sphere", "Cylinder"]
+        object_count = len(objects)
+
+        assert object_count == 3
+
+    def test_resolve_object_names_from_ids(self):
+        """Verify object IDs are resolved to names."""
+        object_names_by_id = {
+            "1": "Cube",
+            "2": "Sphere",
+            "3": "Cylinder",
+        }
+        plate_object_ids = ["1", "3"]
+
+        resolved_names = [object_names_by_id.get(obj_id, f"Object {obj_id}") for obj_id in plate_object_ids]
+
+        assert resolved_names == ["Cube", "Cylinder"]
+
+    def test_resolve_object_names_missing_id(self):
+        """Verify missing object IDs get fallback names."""
+        object_names_by_id = {
+            "1": "Cube",
+        }
+        plate_object_ids = ["1", "99"]  # 99 doesn't exist
+
+        resolved_names = [object_names_by_id.get(obj_id, f"Object {obj_id}") for obj_id in plate_object_ids]
+
+        assert resolved_names == ["Cube", "Object 99"]
+
+    def test_plate_id_alternatives(self):
+        """Verify both 'plater_id' and 'plate_id' keys are supported."""
+        xml_with_plater_id = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <plate>
+                <metadata key="plater_id" value="1"/>
+            </plate>
+        </config>
+        """
+        xml_with_plate_id = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <plate>
+                <metadata key="plate_id" value="2"/>
+            </plate>
+        </config>
+        """
+
+        def extract_plate_id(xml_content):
+            root = ET.fromstring(xml_content)
+            for plate in root.findall(".//plate"):
+                for meta in plate.findall("metadata"):
+                    if meta.get("key") in ("plater_id", "plate_id"):
+                        return meta.get("value")
+            return None
+
+        assert extract_plate_id(xml_with_plater_id) == "1"
+        assert extract_plate_id(xml_with_plate_id) == "2"

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

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

+ 1 - 1
docker-compose.test.yml

@@ -56,7 +56,7 @@ services:
     environment:
       - BAMBUDDY_TEST_URL=http://integration:8000
       - TESTING=1
-    command: ["pytest", "backend/tests/integration/", "-v", "--tb=short", "-p", "no:cacheprovider"]
+    command: ["pytest", "backend/tests/integration/", "-v", "--tb=short", "-p", "no:cacheprovider", "-n", "auto"]
     volumes:
       - ./backend:/app/backend:ro
 

+ 3 - 0
docker-compose.yml

@@ -6,6 +6,9 @@ services:
     #   docker compose up -d          → pulls pre-built image from ghcr.io
     #   docker compose up -d --build  → builds locally from source
     container_name: bambuddy
+    # Run as current user to avoid permission issues with mounted volumes
+    # Override with: PUID=$(id -u) PGID=$(id -g) docker compose up -d
+    user: "${PUID:-1000}:${PGID:-1000}"
     #
     # LINUX: Use host mode for printer discovery and camera streaming
     network_mode: host

+ 7 - 2
frontend/public/sw.js

@@ -1,6 +1,6 @@
 // Bambuddy Service Worker
-const CACHE_NAME = 'bambuddy-v23';
-const STATIC_CACHE = 'bambuddy-static-v23';
+const CACHE_NAME = 'bambuddy-v24';
+const STATIC_CACHE = 'bambuddy-static-v24';
 
 // Static assets to cache on install
 const STATIC_ASSETS = [
@@ -62,6 +62,11 @@ self.addEventListener('fetch', (event) => {
     return;
   }
 
+  // Skip camera stream/snapshot requests - Safari has issues with streaming through SW
+  if (url.pathname.includes('/camera/stream') || url.pathname.includes('/camera/snapshot')) {
+    return;
+  }
+
   // API requests - network first, no cache (real-time data is critical)
   if (url.pathname.startsWith('/api/')) {
     event.respondWith(

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

@@ -95,11 +95,11 @@ describe('API Client Auth Header', () => {
     expect(capturedHeaders!.get('Authorization')).toBeNull();
   });
 
-  it('clears token on 401 Unauthorized response', async () => {
+  it('clears token on 401 with invalid token message', async () => {
     server.use(
       http.get('/api/v1/settings/spoolman', () => {
         return HttpResponse.json(
-          { detail: 'Not authenticated' },
+          { detail: 'Could not validate credentials' },
           { status: 401 }
         );
       })
@@ -117,6 +117,29 @@ describe('API Client Auth Header', () => {
     expect(getAuthToken()).toBeNull();
     expect(localStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
   });
+
+  it('does not clear token on 401 with generic auth error', async () => {
+    server.use(
+      http.get('/api/v1/settings/spoolman', () => {
+        return HttpResponse.json(
+          { detail: 'Authentication required' },
+          { status: 401 }
+        );
+      })
+    );
+
+    setAuthToken('valid-token');
+    expect(getAuthToken()).toBe('valid-token');
+
+    try {
+      await api.getSpoolmanSettings();
+    } catch {
+      // Expected to throw
+    }
+
+    // Token should NOT be cleared for generic auth errors (might be timing issue)
+    expect(getAuthToken()).toBe('valid-token');
+  });
 });
 
 describe('FormData requests include auth header', () => {

+ 503 - 0
frontend/src/__tests__/components/ModelViewerModal.test.tsx

@@ -0,0 +1,503 @@
+/**
+ * Tests for the ModelViewerModal component.
+ * Tests fullscreen toggle, plate selector, object counts, and tab switching.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, fireEvent, waitFor } from '@testing-library/react';
+import { render } from '../utils';
+import { ModelViewerModal } from '../../components/ModelViewerModal';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+// Mock ModelViewer and GcodeViewer to avoid WebGL/Three.js issues in tests
+vi.mock('../../components/ModelViewer', () => ({
+  ModelViewer: ({ className }: { className?: string }) => (
+    <div data-testid="model-viewer" className={className}>
+      Model Viewer Mock
+    </div>
+  ),
+}));
+
+vi.mock('../../components/GcodeViewer', () => ({
+  GcodeViewer: ({ className }: { className?: string }) => (
+    <div data-testid="gcode-viewer" className={className}>
+      G-code Viewer Mock
+    </div>
+  ),
+}));
+
+const mockCapabilities = {
+  has_model: true,
+  has_gcode: true,
+  has_source: false,
+  build_volume: { x: 256, y: 256, z: 256 },
+  filament_colors: ['#00ae42'],
+};
+
+const mockPlatesResponse = {
+  is_multi_plate: true,
+  plates: [
+    {
+      index: 1,
+      name: 'Plate 1',
+      has_thumbnail: true,
+      thumbnail_url: '/api/v1/archives/1/plates/1/thumbnail',
+      print_time_seconds: 3600,
+      filament_used_grams: 50.5,
+      object_count: 3,
+      objects: ['Cube', 'Sphere', 'Cylinder'],
+      filaments: [{ color: '#00ae42', type: 'PLA', name: 'Bambu PLA Basic' }],
+    },
+    {
+      index: 2,
+      name: 'Plate 2',
+      has_thumbnail: true,
+      thumbnail_url: '/api/v1/archives/1/plates/2/thumbnail',
+      print_time_seconds: 1800,
+      filament_used_grams: 25.0,
+      object_count: 2,
+      objects: ['Base', 'Cover'],
+      filaments: [{ color: '#ff0000', type: 'PLA', name: 'Red PLA' }],
+    },
+  ],
+};
+
+const mockSinglePlateResponse = {
+  is_multi_plate: false,
+  plates: [
+    {
+      index: 1,
+      name: null,
+      has_thumbnail: false,
+      thumbnail_url: null,
+      print_time_seconds: 7200,
+      filament_used_grams: 100.0,
+      object_count: 5,
+      objects: ['Model 1', 'Model 2', 'Model 3', 'Model 4', 'Model 5'],
+      filaments: [],
+    },
+  ],
+};
+
+describe('ModelViewerModal', () => {
+  const mockOnClose = vi.fn();
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    server.use(
+      http.get('/api/v1/archives/:id/capabilities', () => {
+        return HttpResponse.json(mockCapabilities);
+      }),
+      http.get('/api/v1/archives/:id/plates', () => {
+        return HttpResponse.json(mockPlatesResponse);
+      })
+    );
+  });
+
+  describe('rendering', () => {
+    it('renders the modal with title', async () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model.3mf"
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(screen.getByText('Test Model.3mf')).toBeInTheDocument();
+    });
+
+    it('renders Open in Slicer button', async () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('Open in Slicer')).toBeInTheDocument();
+      });
+    });
+
+    it('shows loading spinner while fetching capabilities', () => {
+      server.use(
+        http.get('/api/v1/archives/:id/capabilities', async () => {
+          await new Promise((r) => setTimeout(r, 100));
+          return HttpResponse.json(mockCapabilities);
+        })
+      );
+
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      const loader = document.querySelector('.animate-spin');
+      expect(loader).toBeInTheDocument();
+    });
+  });
+
+  describe('tabs', () => {
+    it('renders 3D Model and G-code tabs', async () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('3D Model')).toBeInTheDocument();
+        expect(screen.getByText('G-code Preview')).toBeInTheDocument();
+      });
+    });
+
+    it('shows not available label when model is not available', async () => {
+      server.use(
+        http.get('/api/v1/archives/:id/capabilities', () => {
+          return HttpResponse.json({
+            ...mockCapabilities,
+            has_model: false,
+          });
+        })
+      );
+
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('(not available)')).toBeInTheDocument();
+      });
+    });
+
+    it('shows not sliced label when gcode is not available', async () => {
+      server.use(
+        http.get('/api/v1/archives/:id/capabilities', () => {
+          return HttpResponse.json({
+            ...mockCapabilities,
+            has_gcode: false,
+          });
+        })
+      );
+
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('(not sliced)')).toBeInTheDocument();
+      });
+    });
+
+    it('disables tab when capability is not available', async () => {
+      server.use(
+        http.get('/api/v1/archives/:id/capabilities', () => {
+          return HttpResponse.json({
+            ...mockCapabilities,
+            has_gcode: false,
+          });
+        })
+      );
+
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        const gcodeTab = screen.getByText('G-code Preview').closest('button');
+        expect(gcodeTab).toBeDisabled();
+      });
+    });
+  });
+
+  describe('fullscreen', () => {
+    it('renders fullscreen toggle button', async () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        // Look for the maximize icon button
+        const buttons = screen.getAllByRole('button');
+        const fullscreenButton = buttons.find(
+          (btn) => btn.querySelector('.lucide-maximize-2') || btn.title === 'Enter fullscreen'
+        );
+        expect(fullscreenButton).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('object count', () => {
+    it('displays object count for multi-plate files', async () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        // Total objects across both plates = 3 + 2 = 5
+        // The header shows "All Plates: 5 objects" in a span
+        const objectCountBadge = screen.getByText(/All Plates.*5 objects/);
+        expect(objectCountBadge).toBeInTheDocument();
+      });
+    });
+
+    it('updates object count when plate is selected', async () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('Plate 1')).toBeInTheDocument();
+      });
+
+      // Click on Plate 1
+      fireEvent.click(screen.getByText('Plate 1'));
+
+      await waitFor(() => {
+        // Plate 1 has 3 objects - header should update to show "Plate 1: 3 objects"
+        const objectCountBadge = screen.getByText(/Plate 1.*3 objects/);
+        expect(objectCountBadge).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('plate selector', () => {
+    it('shows plates panel for multi-plate files', async () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('Plates')).toBeInTheDocument();
+        // Use getAllByText for "All Plates" since it appears in header and panel
+        const allPlatesElements = screen.getAllByText('All Plates');
+        expect(allPlatesElements.length).toBeGreaterThan(0);
+        expect(screen.getByText('2 plates')).toBeInTheDocument();
+      });
+    });
+
+    it('shows individual plate buttons', async () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('Plate 1')).toBeInTheDocument();
+        expect(screen.getByText('Plate 2')).toBeInTheDocument();
+      });
+    });
+
+    it('shows object count for each plate', async () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        // Each plate shows its object count in the grid
+        expect(screen.getByText('3 objects')).toBeInTheDocument();
+        expect(screen.getByText('2 objects')).toBeInTheDocument();
+      });
+    });
+
+    it('hides plates panel for single-plate files', async () => {
+      server.use(
+        http.get('/api/v1/archives/:id/plates', () => {
+          return HttpResponse.json(mockSinglePlateResponse);
+        })
+      );
+
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        // Should show object count but not plate selector
+        expect(screen.getByText(/5 objects/)).toBeInTheDocument();
+      });
+
+      // Plates panel should not be shown for single plate
+      expect(screen.queryByText('2 plates')).not.toBeInTheDocument();
+    });
+
+    it('selects All Plates by default', async () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        // Find the All Plates button in the grid (the one with "2 plates" sibling text)
+        const platesCountText = screen.getByText('2 plates');
+        const allPlatesButton = platesCountText.closest('button');
+        // The selected button should have the green border class
+        expect(allPlatesButton).toHaveClass('border-bambu-green');
+      });
+    });
+
+    it('allows plate selection via click', async () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('Plate 1')).toBeInTheDocument();
+      });
+
+      // Click on Plate 1 - this should not throw
+      const plate1Button = screen.getByText('Plate 1').closest('button');
+      expect(plate1Button).toBeInTheDocument();
+      fireEvent.click(plate1Button!);
+
+      // After clicking, the header should show Plate 1 info
+      await waitFor(() => {
+        expect(screen.getByText(/Plate 1.*3 objects/)).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('close behavior', () => {
+    it('calls onClose when X button is clicked', async () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      const closeButton = screen.getAllByRole('button').find(
+        (btn) => btn.querySelector('.lucide-x')
+      );
+
+      if (closeButton) {
+        fireEvent.click(closeButton);
+        expect(mockOnClose).toHaveBeenCalled();
+      }
+    });
+
+    it('calls onClose when Escape key is pressed', () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      fireEvent.keyDown(window, { key: 'Escape' });
+      expect(mockOnClose).toHaveBeenCalled();
+    });
+
+    it('calls onClose when backdrop is clicked', () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      const backdrop = document.querySelector('.fixed.inset-0');
+      if (backdrop) {
+        fireEvent.click(backdrop);
+        expect(mockOnClose).toHaveBeenCalled();
+      }
+    });
+  });
+
+  describe('library file mode', () => {
+    it('renders for library file', async () => {
+      server.use(
+        http.get('/api/v1/library/files/:id/plates', () => {
+          return HttpResponse.json(mockSinglePlateResponse);
+        })
+      );
+
+      render(
+        <ModelViewerModal
+          libraryFileId={1}
+          title="Library Model.3mf"
+          fileType="3mf"
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(screen.getByText('Library Model.3mf')).toBeInTheDocument();
+
+      await waitFor(() => {
+        expect(screen.getByText('3D Model')).toBeInTheDocument();
+      });
+    });
+
+    it('disables Open in Slicer for non-3mf library files', async () => {
+      render(
+        <ModelViewerModal
+          libraryFileId={1}
+          title="Model.stl"
+          fileType="stl"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        const slicerButton = screen.getByText('Open in Slicer').closest('button');
+        expect(slicerButton).toBeDisabled();
+      });
+    });
+  });
+});

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

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

+ 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
   // ========================================================================
 
-  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', () => {

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

@@ -84,8 +84,14 @@ describe('ArchivesPage', () => {
       http.get('/api/v1/archives/tags', () => {
         return HttpResponse.json(['test', 'calibration', 'functional']);
       }),
-      http.get('/api/v1/archives/:id/plates', () => {
-        return HttpResponse.json([]);
+      http.get('/api/v1/archives/:id/plates', ({ params }) => {
+        const archiveId = Number(params.id);
+        return HttpResponse.json({
+          archive_id: Number.isFinite(archiveId) ? archiveId : 0,
+          filename: 'sample.3mf',
+          plates: [],
+          is_multi_plate: false,
+        });
       }),
       http.get('/api/v1/archives/:id/filament-requirements', () => {
         return HttpResponse.json([]);

+ 144 - 57
frontend/src/__tests__/pages/FileManagerPage.test.tsx

@@ -77,6 +77,20 @@ const mockFiles = [
     duplicate_count: 2,
     created_at: '2024-01-02T00:00:00Z',
   },
+  {
+    id: 3,
+    filename: 'cube.gcode.3mf',
+    file_path: '/library/cube.gcode.3mf',
+    file_size: 2048576,
+    file_type: '3mf',
+    folder_id: null,
+    thumbnail_path: '/thumbnails/3.png',
+    print_name: 'Cube',
+    print_time_seconds: 1800,
+    print_count: 2,
+    duplicate_count: 0,
+    created_at: '2024-01-03T00:00:00Z',
+  },
 ];
 
 const mockStats = {
@@ -459,8 +473,27 @@ describe('FileManagerPage', () => {
     });
   });
 
-  describe('add to queue', () => {
-    it('shows add to queue button for sliced files', async () => {
+  describe('schedule print', () => {
+    it('shows schedule print button when one sliced file is selected', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Benchy')).toBeInTheDocument();
+      });
+
+      // Select a sliced file (benchy.gcode.3mf) by clicking on its card
+      const fileCard = screen.getByText('Benchy').closest('div[class*="cursor-pointer"]');
+      if (fileCard) {
+        await user.click(fileCard);
+      }
+
+      await waitFor(() => {
+        expect(screen.getByText(/Schedule/)).toBeInTheDocument();
+      });
+    });
+
+    it('hides schedule print button when multiple files are selected', async () => {
       const user = userEvent.setup();
       render(<FileManagerPage />);
 
@@ -468,11 +501,12 @@ describe('FileManagerPage', () => {
         expect(screen.getByText('Select All')).toBeInTheDocument();
       });
 
-      // Select a sliced file (benchy.gcode.3mf)
+      // Select all files
       await user.click(screen.getByText('Select All'));
 
       await waitFor(() => {
-        expect(screen.getByText(/Add to Queue/)).toBeInTheDocument();
+        // Schedule button should not be present when multiple files are selected
+        expect(screen.queryByText(/Schedule/)).not.toBeInTheDocument();
       });
     });
   });
@@ -535,7 +569,7 @@ describe('FileManagerPage', () => {
     });
   });
 
-  describe('upload modal STL options', () => {
+  describe('upload modal with advanced 3MF support', () => {
     it('opens upload modal', async () => {
       const user = userEvent.setup();
       render(<FileManagerPage />);
@@ -552,7 +586,7 @@ describe('FileManagerPage', () => {
       });
     });
 
-    it('shows STL thumbnail option when STL file is added', async () => {
+    it('shows 3MF extraction info when 3MF file is added', async () => {
       const user = userEvent.setup();
       render(<FileManagerPage />);
 
@@ -566,24 +600,24 @@ describe('FileManagerPage', () => {
         expect(screen.getByText('Upload Files')).toBeInTheDocument();
       });
 
-      // Create a mock STL file
-      const stlFile = new File(['solid test'], 'model.stl', { type: 'application/sla' });
+      // Create a mock 3MF file
+      const threemfFile = new File(['content'], 'model.gcode.3mf', { type: 'application/octet-stream' });
 
       // Get the hidden file input
       const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
       expect(fileInput).toBeInTheDocument();
 
       // Simulate file selection
-      await user.upload(fileInput, stlFile);
+      await user.upload(fileInput, threemfFile);
 
-      // STL thumbnail option should appear
+      // 3MF extraction info should appear
       await waitFor(() => {
-        expect(screen.getByText('STL thumbnail generation')).toBeInTheDocument();
-        expect(screen.getByText('Generate thumbnails for STL files')).toBeInTheDocument();
+        expect(screen.getByText('3MF files detected')).toBeInTheDocument();
+        expect(screen.getByText(/Printer model.*will be automatically extracted/i)).toBeInTheDocument();
       });
     });
 
-    it('STL thumbnail checkbox is checked by default', async () => {
+    it('shows STL thumbnail option when STL file is added', async () => {
       const user = userEvent.setup();
       render(<FileManagerPage />);
 
@@ -597,79 +631,132 @@ describe('FileManagerPage', () => {
         expect(screen.getByText('Upload Files')).toBeInTheDocument();
       });
 
-      // Add an STL file
+      // Create a mock STL file
       const stlFile = new File(['solid test'], 'model.stl', { type: 'application/sla' });
+
+      // Get the hidden file input
       const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      expect(fileInput).toBeInTheDocument();
+
+      // Simulate file selection
       await user.upload(fileInput, stlFile);
 
+      // STL thumbnail option should appear
       await waitFor(() => {
-        expect(screen.getByText('Generate thumbnails for STL files')).toBeInTheDocument();
+        expect(screen.getByText('STL thumbnail generation')).toBeInTheDocument();
+        expect(screen.getByText(/Thumbnails can be generated/i)).toBeInTheDocument();
       });
-
-      // Checkbox should be checked by default
-      const checkbox = screen.getByRole('checkbox', { name: /Generate thumbnails for STL files/i });
-      expect(checkbox).toBeChecked();
     });
+  });
 
-    it('can toggle STL thumbnail checkbox', async () => {
-      const user = userEvent.setup();
-      render(<FileManagerPage />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Upload')).toBeInTheDocument();
-      });
+  describe('authentication-based UI changes', () => {
+    it('hides "Uploaded By" column and user filter when auth is disabled', async () => {
+      // Mock auth disabled (default)
+      server.use(
+        http.get('*/api/v1/auth/status', () => {
+          return HttpResponse.json({
+            auth_enabled: false,
+            requires_setup: false,
+          });
+        }),
+        http.get('/api/v1/library/files', () => {
+          return HttpResponse.json([
+            {
+              id: 1,
+              filename: 'test.3mf',
+              file_path: '/library/test.3mf',
+              file_size: 1048576,
+              file_type: '3mf',
+              folder_id: null,
+              thumbnail_path: null,
+              print_name: 'Test File',
+              print_time_seconds: 3600,
+              print_count: 0,
+              duplicate_count: 0,
+              created_at: '2024-01-01T00:00:00Z',
+              created_by_username: 'testuser',
+            },
+          ]);
+        })
+      );
 
-      await user.click(screen.getByText('Upload'));
+      render(<FileManagerPage />);
 
+      // Switch to list view to see the column headers
       await waitFor(() => {
-        expect(screen.getByText('Upload Files')).toBeInTheDocument();
+        expect(screen.getByText('Test File')).toBeInTheDocument();
       });
 
-      // Add an STL file
-      const stlFile = new File(['solid test'], 'model.stl', { type: 'application/sla' });
-      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
-      await user.upload(fileInput, stlFile);
+      const user = userEvent.setup();
+      const listViewButton = screen.getByRole('button', { name: /list/i });
+      await user.click(listViewButton);
 
+      // "Uploaded By" column header should not be present
       await waitFor(() => {
-        expect(screen.getByText('Generate thumbnails for STL files')).toBeInTheDocument();
+        expect(screen.queryByText('Uploaded By')).not.toBeInTheDocument();
       });
 
-      const checkbox = screen.getByRole('checkbox', { name: /Generate thumbnails for STL files/i });
-      expect(checkbox).toBeChecked();
-
-      // Toggle off
-      await user.click(checkbox);
-      expect(checkbox).not.toBeChecked();
-
-      // Toggle back on
-      await user.click(checkbox);
-      expect(checkbox).toBeChecked();
+      // User filter dropdown should not be present
+      expect(screen.queryByPlaceholderText('Filter by user')).not.toBeInTheDocument();
     });
 
-    it('shows STL thumbnail option for ZIP files', async () => {
-      const user = userEvent.setup();
+    it('shows "Uploaded By" column and user filter when auth is enabled', async () => {
+      // Mock auth enabled
+      server.use(
+        http.get('*/api/v1/auth/status', () => {
+          return HttpResponse.json({
+            auth_enabled: true,
+            requires_setup: false,
+          });
+        }),
+        http.get('/api/v1/library/files', () => {
+          return HttpResponse.json([
+            {
+              id: 1,
+              filename: 'test.3mf',
+              file_path: '/library/test.3mf',
+              file_size: 1048576,
+              file_type: '3mf',
+              folder_id: null,
+              thumbnail_path: null,
+              print_name: 'Test File',
+              print_time_seconds: 3600,
+              print_count: 0,
+              duplicate_count: 0,
+              created_at: '2024-01-01T00:00:00Z',
+              created_by_username: 'testuser',
+            },
+          ]);
+        }),
+        http.get('/api/v1/users/', () => {
+          return HttpResponse.json([
+            { id: 1, username: 'testuser' },
+            { id: 2, username: 'admin' },
+          ]);
+        })
+      );
+
       render(<FileManagerPage />);
 
+      // Switch to list view to see the column headers
       await waitFor(() => {
-        expect(screen.getByText('Upload')).toBeInTheDocument();
+        expect(screen.getByText('Test File')).toBeInTheDocument();
       });
 
-      await user.click(screen.getByText('Upload'));
+      const user = userEvent.setup();
+      const listViewButton = screen.getByRole('button', { name: /list/i });
+      await user.click(listViewButton);
 
+      // "Uploaded By" column header should be present
       await waitFor(() => {
-        expect(screen.getByText('Upload Files')).toBeInTheDocument();
+        expect(screen.getByText('Uploaded By')).toBeInTheDocument();
       });
 
-      // Create a mock ZIP file
-      const zipFile = new File(['PK'], 'models.zip', { type: 'application/zip' });
-      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
-      await user.upload(fileInput, zipFile);
+      // User filter dropdown should be present
+      expect(screen.getByPlaceholderText('Filter by user')).toBeInTheDocument();
 
-      // STL thumbnail option should appear for ZIP files (may contain STLs)
-      await waitFor(() => {
-        expect(screen.getByText('STL thumbnail generation')).toBeInTheDocument();
-        expect(screen.getByText(/ZIP files may contain STL files/)).toBeInTheDocument();
-      });
+      // Username should be displayed in the column
+      expect(screen.getByText('testuser')).toBeInTheDocument();
     });
   });
 });

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

@@ -1,3 +1,5 @@
+import type { ArchivePlatesResponse, LibraryFilePlatesResponse } from '../types/plates';
+
 const API_BASE = '/api/v1';
 
 // Auth token storage
@@ -37,16 +39,27 @@ async function request<T>(
   });
 
   if (!response.ok) {
-    // Handle 401 Unauthorized - clear token and redirect to login
-    if (response.status === 401) {
-      setAuthToken(null);
-      // Don't throw here - let the auth context handle redirect
-    }
     const error = await response.json().catch(() => ({}));
     const detail = error.detail;
     const message = typeof detail === 'string'
       ? detail
       : (detail ? JSON.stringify(detail) : `HTTP ${response.status}`);
+
+    // Handle 401 Unauthorized - only clear token if it's actually invalid
+    // Don't clear on "Authentication required" which might be a timing issue
+    if (response.status === 401) {
+      const invalidTokenMessages = [
+        'Could not validate credentials',
+        'Token has expired',
+        'User not found or inactive',
+        'Invalid API key',
+        'API key has expired',
+      ];
+      if (invalidTokenMessages.some(m => message.includes(m))) {
+        setAuthToken(null);
+      }
+    }
+
     throw new Error(message);
   }
 
@@ -2063,6 +2076,33 @@ export const api = {
     }>(`/printers/${printerId}/files?path=${encodeURIComponent(path)}`),
   getPrinterFileDownloadUrl: (printerId: number, path: string) =>
     `${API_BASE}/printers/${printerId}/files/download?path=${encodeURIComponent(path)}`,
+  getPrinterFileGcodeUrl: (printerId: number, path: string) =>
+    `${API_BASE}/printers/${printerId}/files/gcode?path=${encodeURIComponent(path)}`,
+  getPrinterFilePlates: (printerId: number, path: string) =>
+    request<{
+      printer_id: number;
+      path: string;
+      filename: string;
+      plates: Array<{
+        index: number;
+        name: string | null;
+        objects: string[];
+        has_thumbnail: boolean;
+        thumbnail_url: string | null;
+        print_time_seconds: number | null;
+        filament_used_grams: number | null;
+        filaments: Array<{
+          slot_id: number;
+          type: string;
+          color: string;
+          used_grams: number;
+          used_meters: number;
+        }>;
+      }>;
+      is_multi_plate: boolean;
+    }>(`/printers/${printerId}/files/plates?path=${encodeURIComponent(path)}`),
+  getPrinterFilePlateThumbnail: (printerId: number, plateIndex: number, path: string) =>
+    `${API_BASE}/printers/${printerId}/files/plate-thumbnail/${plateIndex}?path=${encodeURIComponent(path)}`,
   downloadPrinterFile: async (printerId: number, path: string): Promise<void> => {
     const headers: Record<string, string> = {};
     if (authToken) {
@@ -2557,27 +2597,7 @@ export const api = {
   getArchiveForSlicer: (id: number, filename: string) =>
     `${API_BASE}/archives/${id}/file/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
   getArchivePlates: (archiveId: number) =>
-    request<{
-      archive_id: number;
-      filename: string;
-      plates: Array<{
-        index: number;
-        name: string | null;
-        objects: string[];
-        has_thumbnail: boolean;
-        thumbnail_url: string | null;
-        print_time_seconds: number | null;
-        filament_used_grams: number | null;
-        filaments: Array<{
-          slot_id: number;
-          type: string;
-          color: string;
-          used_grams: number;
-          used_meters: number;
-        }>;
-      }>;
-      is_multi_plate: boolean;
-    }>(`/archives/${archiveId}/plates`),
+    request<ArchivePlatesResponse>(`/archives/${archiveId}/plates`),
   getArchiveFilamentRequirements: (archiveId: number, plateId?: number) =>
     request<{
       archive_id: number;
@@ -2713,6 +2733,8 @@ export const api = {
   },
   checkFfmpeg: () =>
     request<{ installed: boolean; path: string | null }>('/settings/check-ffmpeg'),
+  getNetworkInterfaces: () =>
+    request<{ interfaces: NetworkInterface[] }>('/settings/network-interfaces'),
 
   // Cloud
   getCloudStatus: () => request<CloudAuthStatus>('/cloud/status'),
@@ -2794,13 +2816,11 @@ export const api = {
 
   // Tasmota Discovery (auto-detects network)
   startTasmotaScan: () =>
-    fetch(`${API_BASE}/smart-plugs/discover/scan`, { method: 'POST' })
-      .then(res => res.ok ? res.json() : res.json().then(e => { throw new Error(e.detail || `HTTP ${res.status}`); })),
+    request<TasmotaScanStatus>('/smart-plugs/discover/scan', { method: 'POST' }),
   getTasmotaScanStatus: () =>
     request<TasmotaScanStatus>('/smart-plugs/discover/status'),
   stopTasmotaScan: () =>
-    fetch(`${API_BASE}/smart-plugs/discover/stop`, { method: 'POST' })
-      .then(res => res.ok ? res.json() : res.json().then(e => { throw new Error(e.detail || `HTTP ${res.status}`); })),
+    request<TasmotaScanStatus>('/smart-plugs/discover/stop', { method: 'POST' }),
   getDiscoveredTasmotaDevices: () =>
     request<DiscoveredTasmotaDevice[]>('/smart-plugs/discover/devices'),
 
@@ -3565,27 +3585,7 @@ export const api = {
       }
     ),
   getLibraryFilePlates: (fileId: number) =>
-    request<{
-      file_id: number;
-      filename: string;
-      plates: Array<{
-        index: number;
-        name: string | null;
-        objects: string[];
-        has_thumbnail: boolean;
-        thumbnail_url: string | null;
-        print_time_seconds: number | null;
-        filament_used_grams: number | null;
-        filaments: Array<{
-          slot_id: number;
-          type: string;
-          color: string;
-          used_grams: number;
-          used_meters: number;
-        }>;
-      }>;
-      is_multi_plate: boolean;
-    }>(`/library/files/${fileId}/plates`),
+    request<LibraryFilePlatesResponse>(`/library/files/${fileId}/plates`),
   getLibraryFileFilamentRequirements: (fileId: number, plateId?: number) =>
     request<{
       file_id: number;
@@ -3802,6 +3802,11 @@ export interface LibraryFile {
   created_by_username: string | null;
   created_at: string;
   updated_at: string;
+  // Metadata fields
+  print_name: string | null;
+  print_time_seconds: number | null;
+  filament_used_grams: number | null;
+  sliced_for_model: string | null;
 }
 
 export interface LibraryFileListItem {
@@ -3820,6 +3825,7 @@ export interface LibraryFileListItem {
   print_name: string | null;
   print_time_seconds: number | null;
   filament_used_grams: number | null;
+  sliced_for_model: string | null;
 }
 
 export interface LibraryFileUpdate {
@@ -3986,9 +3992,17 @@ export interface VirtualPrinterSettings {
   mode: VirtualPrinterMode;
   model: string;
   target_printer_id: number | null;  // For proxy mode
+  remote_interface_ip: string | null;  // For SSDP proxy across networks
   status: VirtualPrinterStatus;
 }
 
+export interface NetworkInterface {
+  name: string;
+  ip: string;
+  netmask: string;
+  subnet: string;
+}
+
 export interface VirtualPrinterModels {
   models: Record<string, string>;  // SSDP code -> display name
   default: string;
@@ -4018,6 +4032,7 @@ export const virtualPrinterApi = {
     mode?: 'immediate' | 'review' | 'print_queue' | 'proxy';
     model?: string;
     target_printer_id?: number;
+    remote_interface_ip?: string;
   }) => {
     const params = new URLSearchParams();
     if (data.enabled !== undefined) params.set('enabled', String(data.enabled));
@@ -4025,6 +4040,7 @@ export const virtualPrinterApi = {
     if (data.mode !== undefined) params.set('mode', data.mode);
     if (data.model !== undefined) params.set('model', data.model);
     if (data.target_printer_id !== undefined) params.set('target_printer_id', String(data.target_printer_id));
+    if (data.remote_interface_ip !== undefined) params.set('remote_interface_ip', data.remote_interface_ip);
 
     return request<VirtualPrinterSettings>(`/settings/virtual-printer?${params.toString()}`, {
       method: 'PUT',

+ 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) {
       const triggerRect = triggerRef.current.getBoundingClientRect();
       const cardHeight = cardRef.current.offsetHeight;
-      const spaceAbove = triggerRect.top;
+      // Account for fixed header (56px) - space above should exclude header area
+      const headerHeight = 56;
+      const spaceAbove = triggerRect.top - headerHeight;
       const spaceBelow = window.innerHeight - triggerRect.bottom;
 
-      // Prefer top, but flip to bottom if not enough space
+      // Prefer top, but flip to bottom if not enough space (accounting for header)
       if (spaceAbove < cardHeight + 12 && spaceBelow > spaceAbove) {
         setPosition('bottom');
       } else {

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

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

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

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

+ 8 - 1
frontend/src/components/GcodeViewer.tsx

@@ -1,6 +1,7 @@
 import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
 import { WebGLPreview } from 'gcode-preview';
 import { Loader2, Layers, ChevronLeft, ChevronRight, FileWarning } from 'lucide-react';
+import { getAuthToken } from '../api/client';
 
 interface GcodeViewerProps {
   gcodeUrl: string;
@@ -63,7 +64,13 @@ export function GcodeViewer({
     previewRef.current = preview;
 
     // Fetch and process gcode
-    fetch(gcodeUrl)
+    const headers: HeadersInit = {};
+    const token = getAuthToken();
+    if (token) {
+      headers['Authorization'] = `Bearer ${token}`;
+    }
+
+    fetch(gcodeUrl, { headers })
       .then(async response => {
         if (!response.ok) {
           if (response.status === 404) {

+ 6 - 1
frontend/src/components/Layout.tsx

@@ -313,8 +313,13 @@ export function Layout() {
   }, [location.pathname, isMobile]);
 
   // Listen for plate detection warnings (objects on plate, print paused)
+  // Only show to users with printers:control permission
   useEffect(() => {
     const handlePlateNotEmpty = (event: Event) => {
+      // Only show alert to users who can control printers
+      if (!hasPermission('printers:control')) {
+        return;
+      }
       const detail = (event as CustomEvent).detail;
       setPlateDetectionAlert({
         printer_id: detail.printer_id,
@@ -324,7 +329,7 @@ export function Layout() {
     };
     window.addEventListener('plate-not-empty', handlePlateNotEmpty);
     return () => window.removeEventListener('plate-not-empty', handlePlateNotEmpty);
-  }, []);
+  }, [hasPermission]);
 
   // Global keyboard shortcuts for navigation
   const handleKeyDown = useCallback((e: KeyboardEvent) => {

+ 437 - 144
frontend/src/components/ModelViewer.tsx

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

+ 475 - 25
frontend/src/components/ModelViewerModal.tsx

@@ -1,16 +1,20 @@
-import { useState, useEffect } from 'react';
-import { X, ExternalLink, Box, Code2, Loader2 } from 'lucide-react';
+import { useState, useEffect, useRef, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { X, ExternalLink, Box, Code2, Loader2, Layers, Check, Maximize2, Minimize2 } from 'lucide-react';
 import { ModelViewer } from './ModelViewer';
 import { GcodeViewer } from './GcodeViewer';
 import { Button } from './Button';
 import { api } from '../api/client';
 import { openInSlicer } from '../utils/slicer';
+import type { ArchivePlatesResponse, LibraryFilePlatesResponse, PlateMetadata } from '../types/plates';
 
 type ViewTab = '3d' | 'gcode';
 
 interface ModelViewerModalProps {
-  archiveId: number;
+  archiveId?: number;
+  libraryFileId?: number;
   title: string;
+  fileType?: string;
   onClose: () => void;
 }
 
@@ -22,10 +26,26 @@ interface Capabilities {
   filament_colors: string[];
 }
 
-export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModalProps) {
+export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, onClose }: ModelViewerModalProps) {
+  const { t } = useTranslation();
+  const isLibrary = libraryFileId != null;
   const [activeTab, setActiveTab] = useState<ViewTab | null>(null);
   const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
   const [loading, setLoading] = useState(true);
+  const [platesData, setPlatesData] = useState<ArchivePlatesResponse | LibraryFilePlatesResponse | null>(null);
+  const [platesLoading, setPlatesLoading] = useState(false);
+  const [selectedPlateId, setSelectedPlateId] = useState<number | null>(null);
+  const [platePage, setPlatePage] = useState(0);
+  const [isFullscreen, setIsFullscreen] = useState(false);
+  const [platePanelHeight, setPlatePanelHeight] = useState<number | null>(null);
+  const [isDraggingDivider, setIsDraggingDivider] = useState(false);
+  const [hasCustomSplit, setHasCustomSplit] = useState(false);
+  const splitContainerRef = useRef<HTMLDivElement>(null);
+  const platesPanelRef = useRef<HTMLDivElement>(null);
+  const dividerHeight = 10;
+  const minPlateHeight = 160;
+  const minViewerPx = 240;
+  const minViewerRatio = 0.35;
 
   // Close on Escape key
   useEffect(() => {
@@ -37,6 +57,31 @@ export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModal
   }, [onClose]);
 
   useEffect(() => {
+    setLoading(true);
+
+    if (isLibrary) {
+      const normalizedType = (fileType || '').toLowerCase();
+      const hasModel = normalizedType === '3mf' || normalizedType === 'stl';
+      const hasGcode = normalizedType === 'gcode' || normalizedType === '3mf';
+      setCapabilities({
+        has_model: hasModel,
+        has_gcode: hasGcode,
+        has_source: false,
+        build_volume: { x: 256, y: 256, z: 256 },
+        filament_colors: [],
+      });
+      setActiveTab(hasModel ? '3d' : hasGcode ? 'gcode' : null);
+      setLoading(false);
+      return;
+    }
+
+    if (!archiveId) {
+      setCapabilities(null);
+      setActiveTab(null);
+      setLoading(false);
+      return;
+    }
+
     api.getArchiveCapabilities(archiveId)
       .then(caps => {
         setCapabilities(caps);
@@ -54,31 +99,213 @@ export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModal
         setActiveTab('3d');
         setLoading(false);
       });
-  }, [archiveId]);
+  }, [archiveId, fileType, isLibrary]);
+
+  useEffect(() => {
+    setPlatesLoading(true);
+    setSelectedPlateId(null);
+    setPlatePage(0);
+
+    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 = useMemo(() => platesData?.plates ?? [], [platesData]);
+  const hasMultiplePlates = (platesData?.is_multi_plate ?? false) && plates.length > 1;
+  const splitFullscreen = isFullscreen && hasMultiplePlates;
+  const selectedPlate: PlateMetadata | null = selectedPlateId == null
+    ? null
+    : plates.find((plate) => plate.index === selectedPlateId) ?? null;
+  const getPlateObjectCount = (plate: PlateMetadata): number => plate.object_count ?? plate.objects?.length ?? 0;
+  const totalObjectCount = plates.reduce((sum, plate) => sum + getPlateObjectCount(plate), 0);
+  const selectedObjectCount = selectedPlate ? getPlateObjectCount(selectedPlate) : totalObjectCount;
+  const objectCountLabel = selectedPlate ? t('modelViewer.plateNumber', { number: selectedPlate.index }) : t('modelViewer.allPlates');
+  const hasObjectCount = plates.length > 0;
+  const platesGridRef = useRef<HTMLDivElement>(null);
+  const platesViewportRef = useRef<HTMLDivElement>(null);
+  const [platesPerPage, setPlatesPerPage] = useState(10);
+  const [plateColumns, setPlateColumns] = useState(3);
+  const shouldPaginatePlates = plates.length > platesPerPage;
+  const totalPlatePages = Math.max(1, Math.ceil(plates.length / platesPerPage));
+  const pagedPlates = shouldPaginatePlates
+    ? plates.slice(platePage * platesPerPage, (platePage + 1) * platesPerPage)
+    : plates;
+
+  useEffect(() => {
+    if (!splitFullscreen) {
+      setPlatesPerPage(10);
+      setPlateColumns(3);
+      return;
+    }
+    const grid = platesGridRef.current;
+    const viewport = platesViewportRef.current;
+    if (!grid || !viewport) return;
+    let rafId = 0;
+    const updateLayout = () => {
+      const availableWidth = viewport.clientWidth;
+      const minButtonWidth = 210;
+      const computedCols = Math.floor(availableWidth / minButtonWidth);
+      const nextCols = Math.max(3, Math.min(5, computedCols || 3));
+      setPlateColumns((prev) => (prev === nextCols ? prev : nextCols));
+
+      const computed = window.getComputedStyle(grid);
+      const rowGap = Number.parseFloat(computed.rowGap || '0');
+      const firstItem = grid.querySelector<HTMLElement>('button');
+      const rowHeight = firstItem?.getBoundingClientRect().height ?? 44;
+      const availableHeight = viewport.clientHeight;
+      const rows = Math.max(1, Math.floor((availableHeight + rowGap) / (rowHeight + rowGap)));
+      const maxSlots = rows * nextCols;
+      const nextPerPage = Math.max(1, maxSlots - 1);
+      setPlatesPerPage((prev) => (prev === nextPerPage ? prev : nextPerPage));
+    };
+    const scheduleUpdate = () => {
+      if (rafId) cancelAnimationFrame(rafId);
+      rafId = requestAnimationFrame(updateLayout);
+    };
+    scheduleUpdate();
+    const resizeObserver = new ResizeObserver(scheduleUpdate);
+    resizeObserver.observe(viewport);
+    resizeObserver.observe(grid);
+    return () => {
+      if (rafId) cancelAnimationFrame(rafId);
+      resizeObserver.disconnect();
+    };
+  }, [splitFullscreen, plates.length]);
+
+  useEffect(() => {
+    if (!shouldPaginatePlates) {
+      setPlatePage(0);
+      return;
+    }
+    setPlatePage((prev) => Math.min(prev, totalPlatePages - 1));
+  }, [plates.length, shouldPaginatePlates, totalPlatePages]);
+
+  useEffect(() => {
+    if (!shouldPaginatePlates || selectedPlateId == null) return;
+    const selectedIndex = plates.findIndex((plate) => plate.index === selectedPlateId);
+    if (selectedIndex < 0) return;
+    const nextPage = Math.floor(selectedIndex / platesPerPage);
+    setPlatePage((prev) => (prev === nextPage ? prev : nextPage));
+  }, [plates, platesPerPage, selectedPlateId, shouldPaginatePlates]);
+
+  useEffect(() => {
+    if (!splitFullscreen) {
+      setPlatePanelHeight(null);
+      setHasCustomSplit(false);
+      return;
+    }
+    if (hasCustomSplit) return;
+    const container = splitContainerRef.current;
+    const panel = platesPanelRef.current;
+    if (!container || !panel) return;
+    const containerHeight = container.clientHeight;
+    if (!containerHeight) return;
+    const minViewerHeight = Math.max(minViewerPx, containerHeight * minViewerRatio);
+    const maxPlateHeight = Math.max(minPlateHeight, containerHeight - dividerHeight - minViewerHeight);
+    const desiredHeight = Math.min(panel.scrollHeight, maxPlateHeight);
+    setPlatePanelHeight(Math.max(minPlateHeight, desiredHeight));
+  }, [splitFullscreen, hasCustomSplit, plates.length, platePage, dividerHeight, minPlateHeight, minViewerPx, minViewerRatio]);
+
+  useEffect(() => {
+    if (!isDraggingDivider) return;
+    const handleMouseMove = (event: MouseEvent) => {
+      const container = splitContainerRef.current;
+      if (!container) return;
+      const rect = container.getBoundingClientRect();
+      const containerHeight = rect.height;
+      if (!containerHeight) return;
+      const minViewerHeight = Math.max(minViewerPx, containerHeight * minViewerRatio);
+      const maxPlateHeight = Math.max(minPlateHeight, containerHeight - dividerHeight - minViewerHeight);
+      const nextHeight = Math.min(maxPlateHeight, Math.max(minPlateHeight, event.clientY - rect.top));
+      setPlatePanelHeight(nextHeight);
+    };
+    const handleMouseUp = () => {
+      setIsDraggingDivider(false);
+      setHasCustomSplit(true);
+    };
+    document.addEventListener('mousemove', handleMouseMove);
+    document.addEventListener('mouseup', handleMouseUp);
+    document.body.style.cursor = 'row-resize';
+    document.body.style.userSelect = 'none';
+
+    return () => {
+      document.removeEventListener('mousemove', handleMouseMove);
+      document.removeEventListener('mouseup', handleMouseUp);
+      document.body.style.cursor = '';
+      document.body.style.userSelect = '';
+    };
+  }, [isDraggingDivider, dividerHeight, minPlateHeight, minViewerPx, minViewerRatio]);
+
+  const canOpenInSlicer = isLibrary ? (fileType || '').toLowerCase() === '3mf' : true;
 
   const handleOpenInSlicer = () => {
+    if (!canOpenInSlicer) return;
     // URL must include .3mf filename for Bambu Studio to recognize the format
     const filename = title || 'model';
-    const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId, filename)}`;
+    if (isLibrary) {
+      const downloadUrl = `${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId!)}`;
+      openInSlicer(downloadUrl);
+      return;
+    }
+    const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId!, filename)}`;
     openInSlicer(downloadUrl);
   };
 
   return (
     <div
-      className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-8"
+      className={`fixed inset-0 bg-black/70 flex items-center justify-center z-50 ${isFullscreen ? 'p-0' : 'p-8'}`}
       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"
+        className={`bg-bambu-dark-secondary border border-bambu-dark-tertiary w-full flex flex-col ${
+          isFullscreen ? 'h-full max-w-none rounded-none' : 'h-[80vh] max-w-4xl rounded-xl'
+        }`}
         onClick={(e) => e.stopPropagation()}
       >
         {/* Header */}
         <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
-          <h2 className="text-lg font-semibold text-white truncate flex-1 mr-4">{title}</h2>
+          <div className="flex items-center gap-3 min-w-0 flex-1 mr-4">
+            <h2 className="text-lg font-semibold text-white truncate">{title}</h2>
+            {hasObjectCount && (
+              <span className="text-xs text-bambu-gray bg-bambu-dark-tertiary/70 px-2 py-1 rounded whitespace-nowrap">
+                {objectCountLabel}: {t('modelViewer.objectCount', { count: selectedObjectCount })}
+              </span>
+            )}
+          </div>
           <div className="flex items-center gap-2">
-            <Button variant="secondary" size="sm" onClick={handleOpenInSlicer}>
+            <Button variant="secondary" size="sm" onClick={handleOpenInSlicer} disabled={!canOpenInSlicer}>
               <ExternalLink className="w-4 h-4" />
-              Open in Slicer
+              {t('modelViewer.openInSlicer')}
+            </Button>
+            <Button
+              variant="secondary"
+              size="sm"
+              onClick={() => setIsFullscreen((prev) => !prev)}
+              title={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
+            >
+              {isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
             </Button>
             <Button variant="ghost" size="sm" onClick={onClose}>
               <X className="w-5 h-5" />
@@ -101,8 +328,8 @@ export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModal
               }`}
             >
               <Box className="w-4 h-4" />
-              3D Model
-              {!capabilities.has_model && <span className="text-xs">(not available)</span>}
+              {t('modelViewer.tabs.model')}
+              {!capabilities.has_model && <span className="text-xs">({t('modelViewer.notAvailable')})</span>}
             </button>
             <button
               onClick={() => capabilities.has_gcode && setActiveTab('gcode')}
@@ -116,8 +343,8 @@ export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModal
               }`}
             >
               <Code2 className="w-4 h-4" />
-              G-code Preview
-              {!capabilities.has_gcode && <span className="text-xs">(not sliced)</span>}
+              {t('modelViewer.tabs.gcode')}
+              {!capabilities.has_gcode && <span className="text-xs">({t('modelViewer.notSliced')})</span>}
             </button>
           </div>
         )}
@@ -129,23 +356,246 @@ export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModal
               <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
             </div>
           ) : activeTab === '3d' && capabilities ? (
-            <ModelViewer
-              url={capabilities.has_source
-                ? api.getSource3mfDownloadUrl(archiveId)
-                : api.getArchiveDownload(archiveId)}
-              buildVolume={capabilities.build_volume}
-              filamentColors={capabilities.filament_colors}
-              className="w-full h-full"
-            />
+            <div
+              ref={splitContainerRef}
+              className={`w-full h-full flex flex-col ${splitFullscreen ? 'gap-0 min-h-0' : 'gap-3'}`}
+            >
+              {hasMultiplePlates && (
+                <div
+                  ref={platesPanelRef}
+                  style={splitFullscreen && platePanelHeight != null ? { height: platePanelHeight } : undefined}
+                  className={`rounded-lg border border-bambu-dark-tertiary bg-bambu-dark p-3 ${splitFullscreen ? 'flex flex-col shrink-0' : ''}`}
+                >
+                  <div className="flex items-center gap-2 text-sm text-bambu-gray mb-2">
+                    <Layers className="w-4 h-4" />
+                    {t('modelViewer.plates')}
+                    {platesLoading && <Loader2 className="w-3 h-3 animate-spin" />}
+                  </div>
+                  <div className={splitFullscreen ? 'flex flex-col min-h-0 flex-1' : undefined}>
+                      <div
+                        ref={platesViewportRef}
+                        className={splitFullscreen ? 'min-h-0 overflow-hidden pr-1 flex-1' : undefined}
+                      >
+                      <div
+                        ref={platesGridRef}
+                        className={splitFullscreen ? 'grid gap-2' : 'grid grid-cols-2 md:grid-cols-3 gap-2'}
+                        style={splitFullscreen ? { gridTemplateColumns: `repeat(${plateColumns}, minmax(0, 1fr))` } : undefined}
+                      >
+                        <button
+                          type="button"
+                          onClick={() => setSelectedPlateId(null)}
+                          className={`flex items-center rounded-lg border text-left transition-colors ${
+                            splitFullscreen ? 'gap-1.5 p-1.5 w-full' : 'gap-2 p-2'
+                          } ${
+                            selectedPlateId == null
+                              ? 'border-bambu-green bg-bambu-green/10'
+                              : 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
+                          }`}
+                        >
+                          <div className={`rounded bg-bambu-dark-tertiary flex items-center justify-center ${
+                            splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'
+                          }`}>
+                            <Layers className={`${splitFullscreen ? 'w-4 h-4' : 'w-5 h-5'} text-bambu-gray`} />
+                          </div>
+                          <div className="min-w-0 flex-1">
+                            <p className={`${splitFullscreen ? 'text-xs' : 'text-sm'} text-white font-medium truncate`}>{t('modelViewer.allPlates')}</p>
+                            <p className={`${splitFullscreen ? 'text-[10px]' : 'text-xs'} text-bambu-gray truncate`}>
+                              {t('modelViewer.plateCount', { count: plates.length })}
+                            </p>
+                          </div>
+                          {selectedPlateId == null && (
+                            <Check className={`${splitFullscreen ? 'w-3.5 h-3.5' : 'w-4 h-4'} text-bambu-green flex-shrink-0`} />
+                          )}
+                        </button>
+                        {pagedPlates.map((plate) => (
+                          <button
+                            key={plate.index}
+                            type="button"
+                            onClick={() => setSelectedPlateId(plate.index)}
+                            className={`flex items-center rounded-lg border text-left transition-colors ${
+                              splitFullscreen ? 'gap-1.5 p-1.5 w-full' : 'gap-2 p-2'
+                            } ${
+                              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={`${splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'} rounded object-cover bg-bambu-dark-tertiary`}
+                              />
+                            ) : (
+                              <div className={`rounded bg-bambu-dark-tertiary flex items-center justify-center ${
+                                splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'
+                              }`}>
+                                <Layers className={`${splitFullscreen ? 'w-4 h-4' : 'w-5 h-5'} text-bambu-gray`} />
+                              </div>
+                            )}
+                            <div className="min-w-0 flex-1">
+                              <p className={`${splitFullscreen ? 'text-xs' : 'text-sm'} text-white font-medium truncate`}>
+                                {plate.name || t('modelViewer.plateNumber', { number: plate.index })}
+                              </p>
+                              <p className={`${splitFullscreen ? 'text-[10px]' : 'text-xs'} text-bambu-gray truncate`}>
+                                {t('modelViewer.objectCount', { count: plate.object_count ?? plate.objects?.length ?? 0 })}
+                              </p>
+                            </div>
+                            {selectedPlateId === plate.index && (
+                              <Check className={`${splitFullscreen ? 'w-3.5 h-3.5' : 'w-4 h-4'} text-bambu-green flex-shrink-0`} />
+                            )}
+                          </button>
+                        ))}
+                      </div>
+                    </div>
+                    {(selectedPlate || shouldPaginatePlates) && (
+                      <div className="mt-auto pt-3 flex items-center gap-4 text-xs text-bambu-gray overflow-x-auto">
+                        {selectedPlate && (
+                          <div className="flex items-center gap-3 whitespace-nowrap">
+                            <span>{t('modelViewer.plateNumber', { number: selectedPlate.index })}</span>
+                            {selectedPlate.print_time_seconds != null && (
+                              <span>{t('modelViewer.eta', { minutes: Math.round(selectedPlate.print_time_seconds / 60) })}</span>
+                            )}
+                            {selectedPlate.filament_used_grams != null && (
+                              <span>{selectedPlate.filament_used_grams.toFixed(1)} g</span>
+                            )}
+                            {selectedPlate.filaments.length > 0 && (
+                              <span>{t('modelViewer.filamentCount', { count: selectedPlate.filaments.length })}</span>
+                            )}
+                          </div>
+                        )}
+                        {shouldPaginatePlates && (
+                          <div className={`flex items-center gap-2 whitespace-nowrap ${selectedPlate ? 'ml-auto' : ''}`}>
+                            <span>{t('modelViewer.pagination.pageOf', { current: platePage + 1, total: totalPlatePages })}</span>
+                            <div className="flex items-center gap-1">
+                              <button
+                                type="button"
+                                onClick={() => setPlatePage((prev) => Math.max(prev - 1, 0))}
+                                disabled={platePage === 0}
+                                className={`px-2 py-1 rounded border text-xs ${
+                                  platePage === 0
+                                    ? 'border-bambu-dark-tertiary text-bambu-gray/40 cursor-not-allowed'
+                                    : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
+                                }`}
+                              >
+                                {t('modelViewer.pagination.prev')}
+                              </button>
+                              {(() => {
+                                const maxVisible = 5;
+                                let start = Math.max(0, platePage - Math.floor(maxVisible / 2));
+                                const end = Math.min(totalPlatePages, start + maxVisible);
+                                if (end - start < maxVisible) {
+                                  start = Math.max(0, end - maxVisible);
+                                }
+                                const pages = Array.from({ length: end - start }, (_, i) => start + i);
+
+                                return (
+                                  <>
+                                    {start > 0 && (
+                                      <button
+                                        type="button"
+                                        onClick={() => setPlatePage(0)}
+                                        className={`px-2 py-1 rounded border text-xs ${
+                                          platePage === 0
+                                            ? 'border-bambu-green text-bambu-green'
+                                            : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
+                                        }`}
+                                      >
+                                        1
+                                      </button>
+                                    )}
+                                    {start > 1 && <span className="px-1">…</span>}
+                                    {pages.map((pageNumber) => (
+                                      <button
+                                        key={pageNumber}
+                                        type="button"
+                                        onClick={() => setPlatePage(pageNumber)}
+                                        className={`px-2 py-1 rounded border text-xs ${
+                                          platePage === pageNumber
+                                            ? 'border-bambu-green text-bambu-green'
+                                            : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
+                                        }`}
+                                      >
+                                        {pageNumber + 1}
+                                      </button>
+                                    ))}
+                                    {end < totalPlatePages - 1 && <span className="px-1">…</span>}
+                                    {end < totalPlatePages && (
+                                      <button
+                                        type="button"
+                                        onClick={() => setPlatePage(totalPlatePages - 1)}
+                                        className={`px-2 py-1 rounded border text-xs ${
+                                          platePage === totalPlatePages - 1
+                                            ? 'border-bambu-green text-bambu-green'
+                                            : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
+                                        }`}
+                                      >
+                                        {totalPlatePages}
+                                      </button>
+                                    )}
+                                  </>
+                                );
+                              })()}
+                              <button
+                                type="button"
+                                onClick={() => setPlatePage((prev) => Math.min(prev + 1, totalPlatePages - 1))}
+                                disabled={platePage >= totalPlatePages - 1}
+                                className={`px-2 py-1 rounded border text-xs ${
+                                  platePage >= totalPlatePages - 1
+                                    ? 'border-bambu-dark-tertiary text-bambu-gray/40 cursor-not-allowed'
+                                    : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
+                                }`}
+                              >
+                                {t('modelViewer.pagination.next')}
+                              </button>
+                            </div>
+                          </div>
+                        )}
+                      </div>
+                    )}
+                  </div>
+                </div>
+              )}
+              {splitFullscreen && (
+                <div
+                  role="separator"
+                  aria-orientation="horizontal"
+                  onMouseDown={(event) => {
+                    event.preventDefault();
+                    setIsDraggingDivider(true);
+                    setHasCustomSplit(true);
+                  }}
+                  className={`h-2 cursor-row-resize flex items-center justify-center ${
+                    isDraggingDivider ? 'bg-bambu-dark-tertiary' : 'bg-bambu-dark-secondary/60 hover:bg-bambu-dark-tertiary'
+                  }`}
+                >
+                  <div className="w-12 h-1 rounded-full bg-bambu-gray/50" />
+                </div>
+              )}
+              <div className={`flex-1 ${splitFullscreen ? 'min-h-0' : ''}`}>
+                  <ModelViewer
+                    url={isLibrary
+                      ? api.getLibraryFileDownloadUrl(libraryFileId!)
+                      : (capabilities.has_source
+                        ? api.getSource3mfDownloadUrl(archiveId!)
+                        : api.getArchiveDownload(archiveId!))}
+                    fileType={fileType}
+                    buildVolume={capabilities.build_volume}
+                    filamentColors={capabilities.filament_colors}
+                    selectedPlateId={selectedPlateId}
+                    className="w-full h-full"
+                  />
+              </div>
+            </div>
           ) : activeTab === 'gcode' && capabilities ? (
             <GcodeViewer
-              gcodeUrl={api.getArchiveGcode(archiveId)}
+              gcodeUrl={isLibrary ? api.getLibraryFileGcodeUrl(libraryFileId!) : api.getArchiveGcode(archiveId!)}
               filamentColors={capabilities.filament_colors}
               className="w-full h-full"
             />
           ) : (
             <div className="w-full h-full flex items-center justify-center text-bambu-gray">
-              No preview available for this file
+              {t('modelViewer.noPreview')}
             </div>
           )}
         </div>

+ 11 - 2
frontend/src/components/PrintModal/index.tsx

@@ -10,6 +10,7 @@ import { useToast } from '../../contexts/ToastContext';
 import { useFilamentMapping } from '../../hooks/useFilamentMapping';
 import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
 import { isPlaceholderDate } from '../../utils/amsHelpers';
+import { toDateTimeLocalValue } from '../../utils/date';
 import { PrinterSelector } from './PrinterSelector';
 import { PlateSelector } from './PlateSelector';
 import { FilamentMapping } from './FilamentMapping';
@@ -90,7 +91,8 @@ export function PrintModal({
       let scheduledTime = '';
       if (queueItem.scheduled_time && !isPlaceholderDate(queueItem.scheduled_time)) {
         const date = new Date(queueItem.scheduled_time);
-        scheduledTime = date.toISOString().slice(0, 16);
+        // Use toDateTimeLocalValue to convert UTC to local time for datetime-local input
+        scheduledTime = toDateTimeLocalValue(date);
       }
 
       return {
@@ -180,8 +182,15 @@ export function PrintModal({
     enabled: !!archiveId && !isLibraryFile,
   });
 
+  // Fetch library file details to get sliced_for_model
+  const { data: libraryFileDetails } = useQuery({
+    queryKey: ['library-file', libraryFileId],
+    queryFn: () => api.getLibraryFile(libraryFileId!),
+    enabled: isLibraryFile && !!libraryFileId,
+  });
+
   // Get sliced_for_model from archive or library file
-  const slicedForModel = archiveDetails?.sliced_for_model || null;
+  const slicedForModel = archiveDetails?.sliced_for_model || libraryFileDetails?.sliced_for_model || null;
 
   // Fetch plates for archives
   const { data: archivePlatesData, isError: archivePlatesError } = useQuery({

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

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

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

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

+ 51 - 7
frontend/src/i18n/locales/de.ts

@@ -578,7 +578,7 @@ export default {
       object: '{{count}} Objekt',
       objects: '{{count}} Objekte',
       slicedFor: 'Geslict für {{model}}',
-      uploadedBy: 'Hochgeladen von {{name}}',
+      uploadedBy: 'Hochgeladen von',
       noPermissionReprint: 'Sie haben keine Berechtigung, erneut zu drucken',
       noPermissionEdit: 'Sie haben keine Berechtigung, Archive zu bearbeiten',
       noPermissionDelete: 'Sie haben keine Berechtigung, Archive zu löschen',
@@ -687,6 +687,7 @@ export default {
       pending: 'Ausstehend',
       waiting: 'Wartend',
       printing: 'Druckt',
+      paused: 'Pausiert',
       completed: 'Abgeschlossen',
       failed: 'Fehlgeschlagen',
       skipped: 'Übersprungen',
@@ -1837,6 +1838,8 @@ export default {
     zipMayContainStl: 'ZIP-Dateien können STL-Dateien enthalten. Vorschaubilder können während der Extraktion generiert werden.',
     thumbnailsCanBeGenerated: 'Vorschaubilder können für STL-Dateien generiert werden. Große Modelle benötigen möglicherweise mehr Zeit.',
     generateThumbnailsForStl: 'Vorschaubilder für STL-Dateien generieren',
+    threemfDetected: '3MF-Dateien erkannt',
+    threemfExtractionInfo: 'Druckermodell, Material, Farbe und Druckeinstellungen werden automatisch aus 3MF-Dateien extrahiert.',
     willBeExtracted: 'Wird extrahiert',
     filesExtracted: '{{count}} Dateien extrahiert',
     uploadComplete: 'Upload abgeschlossen: {{succeeded}} erfolgreich',
@@ -1846,6 +1849,7 @@ export default {
     linkTo: 'Verknüpfen mit...',
     linkToProjectOrArchive: 'Mit Projekt oder Archiv verknüpfen',
     addToQueue: 'Zur Warteschlange',
+    schedulePrint: 'Planen',
     generateThumbnail: 'Vorschaubild generieren',
     generateThumbnails: 'Vorschaubilder generieren',
     generateThumbnailsForMissing: 'Vorschaubilder für STL-Dateien ohne Vorschau generieren',
@@ -1881,7 +1885,7 @@ export default {
     noMatchingFilesDescription: 'Keine Dateien entsprechen Ihren aktuellen Such- oder Filterkriterien.',
     clearFilters: 'Filter zurücksetzen',
     printedCount: '{{count}}x gedruckt',
-    uploadedBy: 'Hochgeladen von {{name}}',
+    uploadedBy: 'Hochgeladen von',
     deleteFolder: 'Ordner löschen',
     deleteFile: 'Datei löschen',
     deleteFilesCount: '{{count}} Dateien löschen',
@@ -2477,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.',
       noPrinters: 'Keine Drucker konfiguriert. Füge zuerst einen Drucker hinzu, um den Proxy-Modus zu verwenden.',
     },
+    remoteInterface: {
+      title: 'Slicer-Netzwerkschnittstelle',
+      configured: 'SSDP-Proxy aktiviert',
+      optional: 'Optional - für SSDP-Erkennung über Netzwerke hinweg',
+      placeholder: 'Schnittstelle für Slicer-Netzwerk auswählen...',
+      hint: 'Wähle die Netzwerkschnittstelle, die mit dem Slicer verbunden ist. Ermöglicht automatische Druckererkennung in Bambu Studio.',
+    },
     mode: {
       title: 'Modus',
       archive: 'Archivieren',
@@ -2502,11 +2513,12 @@ export default {
       step4: 'Der "Bambuddy"-Drucker sollte in der Erkennungsliste erscheinen',
       step5: 'Verbinde mit dem von dir gesetzten Zugangscode',
       step6: 'Wenn du zu Bambuddy "druckst", wird die 3MF-Datei stattdessen archiviert',
-      proxyStep1: 'Setze die Zieldrucker-IP (muss in deinem LAN sein)',
-      proxyStep2: 'Konfiguriere Portweiterleitung zu Bambuddy (Ports 9990, 8883)',
-      proxyStep3: 'Füge in deinem Slicer manuell einen Netzwerkdrucker hinzu',
-      proxyStep4: 'Gib Bambuddys externe Adresse und den Zugangscode des Druckers ein',
-      proxyStep5: 'Drucke wie gewohnt - der Datenverkehr wird an den echten Drucker weitergeleitet',
+      proxyStep1: 'Wähle den Zieldrucker (muss im LAN-Modus sein)',
+      proxyStep2: 'Bei Netzwerkübergreifend: Wähle die Slicer-Netzwerkschnittstelle',
+      proxyStep3: 'Aktiviere den Proxy - Drucker erscheint per SSDP in der Slicer-Erkennung',
+      proxyStep4: 'Verbinde mit dem Zugangscode des Druckers',
+      proxyStep5: 'Drucke wie gewohnt - der Datenverkehr wird über Bambuddy weitergeleitet',
+      proxyStep6: 'Kamera-Streaming erfordert NAT/IP-Weiterleitung (siehe Dokumentation)',
     },
     status: {
       title: 'Status-Details',
@@ -2531,6 +2543,38 @@ export default {
     },
   },
 
+  // Model Viewer
+  modelViewer: {
+    openInSlicer: 'Im Slicer öffnen',
+    tabs: {
+      model: '3D-Modell',
+      gcode: 'G-Code Vorschau',
+    },
+    notAvailable: 'nicht verfügbar',
+    notSliced: 'nicht geslicet',
+    plates: 'Platten',
+    allPlates: 'Alle Platten',
+    plateNumber: 'Platte {{number}}',
+    plateCount: '{{count}} Platte',
+    plateCount_other: '{{count}} Platten',
+    objectCount: '{{count}} Objekt',
+    objectCount_other: '{{count}} Objekte',
+    filamentCount: '{{count}} Filament',
+    filamentCount_other: '{{count}} Filamente',
+    eta: 'ETA {{minutes}} Min',
+    noPreview: 'Keine Vorschau für diese Datei verfügbar',
+    pagination: {
+      pageOf: 'Seite {{current}} von {{total}}',
+      prev: 'Zurück',
+      next: 'Weiter',
+    },
+    errors: {
+      failedToLoad: 'Datei konnte nicht geladen werden',
+      noMeshes: 'Keine Meshes in 3MF-Datei gefunden',
+      unsupportedFormat: 'Nicht unterstütztes Dateiformat',
+    },
+  },
+
   // Maintenance type descriptions (built-in)
   maintenanceDescriptions: {
     lubricateRails: 'Schmiermittel auf Linearschienen für sanfte Bewegung auftragen',

+ 51 - 7
frontend/src/i18n/locales/en.ts

@@ -578,7 +578,7 @@ export default {
       object: '{{count}} object',
       objects: '{{count}} objects',
       slicedFor: 'Sliced for {{model}}',
-      uploadedBy: 'Uploaded by {{name}}',
+      uploadedBy: 'Uploaded By',
       noPermissionReprint: 'You do not have permission to reprint',
       noPermissionEdit: 'You do not have permission to edit archives',
       noPermissionDelete: 'You do not have permission to delete archives',
@@ -687,6 +687,7 @@ export default {
       pending: 'Pending',
       waiting: 'Waiting',
       printing: 'Printing',
+      paused: 'Paused',
       completed: 'Completed',
       failed: 'Failed',
       skipped: 'Skipped',
@@ -1837,6 +1838,8 @@ export default {
     zipMayContainStl: 'ZIP files may contain STL files. Thumbnails can be generated during extraction.',
     thumbnailsCanBeGenerated: 'Thumbnails can be generated for STL files. Large models may take longer to process.',
     generateThumbnailsForStl: 'Generate thumbnails for STL files',
+    threemfDetected: '3MF files detected',
+    threemfExtractionInfo: 'Printer model, material, color, and print settings will be automatically extracted from 3MF files.',
     willBeExtracted: 'Will be extracted',
     filesExtracted: '{{count}} files extracted',
     uploadComplete: 'Upload complete: {{succeeded}} succeeded',
@@ -1846,6 +1849,7 @@ export default {
     linkTo: 'Link to...',
     linkToProjectOrArchive: 'Link to project or archive',
     addToQueue: 'Add to Queue',
+    schedulePrint: 'Schedule',
     generateThumbnail: 'Generate Thumbnail',
     generateThumbnails: 'Generate Thumbnails',
     generateThumbnailsForMissing: 'Generate thumbnails for STL files missing them',
@@ -1881,7 +1885,7 @@ export default {
     noMatchingFilesDescription: 'No files match your current search or filter criteria.',
     clearFilters: 'Clear filters',
     printedCount: 'Printed {{count}}x',
-    uploadedBy: 'Uploaded by {{name}}',
+    uploadedBy: 'Uploaded By',
     deleteFolder: 'Delete Folder',
     deleteFile: 'Delete File',
     deleteFilesCount: 'Delete {{count}} Files',
@@ -2477,6 +2481,13 @@ export default {
       hint: 'Select the printer to proxy slicer traffic to. The printer must be in LAN mode.',
       noPrinters: 'No printers configured. Add a printer first to use proxy mode.',
     },
+    remoteInterface: {
+      title: 'Slicer Network Interface',
+      configured: 'SSDP proxy enabled',
+      optional: 'Optional - for SSDP discovery across networks',
+      placeholder: 'Select interface for slicer network...',
+      hint: 'Select the network interface connected to the slicer. Enables automatic printer discovery in Bambu Studio.',
+    },
     mode: {
       title: 'Mode',
       archive: 'Archive',
@@ -2502,11 +2513,12 @@ export default {
       step4: 'The "Bambuddy" printer should appear in the discovery list',
       step5: 'Connect using the access code you set',
       step6: 'When you "print" to Bambuddy, the 3MF file is archived instead',
-      proxyStep1: 'Set the target printer IP (must be on your LAN)',
-      proxyStep2: 'Configure port forwarding to Bambuddy (ports 9990, 8883)',
-      proxyStep3: 'In your slicer, manually add a network printer',
-      proxyStep4: "Enter Bambuddy's external address and printer's access code",
-      proxyStep5: 'Print as normal - traffic is relayed to the real printer',
+      proxyStep1: 'Select the target printer (must be in LAN mode)',
+      proxyStep2: 'For cross-network: select the slicer network interface',
+      proxyStep3: 'Enable the proxy - printer appears in slicer discovery via SSDP',
+      proxyStep4: 'Connect using the printer\'s access code',
+      proxyStep5: 'Print as normal - traffic is relayed through Bambuddy',
+      proxyStep6: 'Camera streaming requires NAT/IP forwarding (see docs)',
     },
     status: {
       title: 'Status Details',
@@ -2531,6 +2543,38 @@ export default {
     },
   },
 
+  // Model Viewer
+  modelViewer: {
+    openInSlicer: 'Open in Slicer',
+    tabs: {
+      model: '3D Model',
+      gcode: 'G-code Preview',
+    },
+    notAvailable: 'not available',
+    notSliced: 'not sliced',
+    plates: 'Plates',
+    allPlates: 'All Plates',
+    plateNumber: 'Plate {{number}}',
+    plateCount: '{{count}} plate',
+    plateCount_other: '{{count}} plates',
+    objectCount: '{{count}} object',
+    objectCount_other: '{{count}} objects',
+    filamentCount: '{{count}} filament',
+    filamentCount_other: '{{count}} filaments',
+    eta: 'ETA {{minutes}} min',
+    noPreview: 'No preview available for this file',
+    pagination: {
+      pageOf: 'Page {{current}} of {{total}}',
+      prev: 'Prev',
+      next: 'Next',
+    },
+    errors: {
+      failedToLoad: 'Failed to load file',
+      noMeshes: 'No meshes found in 3MF file',
+      unsupportedFormat: 'Unsupported file format',
+    },
+  },
+
   // Maintenance type descriptions (built-in)
   maintenanceDescriptions: {
     lubricateRails: 'Apply lubricant to linear rails for smooth motion',

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

@@ -549,6 +549,7 @@ export default {
     status: {
       pending: '待機中',
       printing: '印刷中',
+      paused: '一時停止',
       completed: '完了',
       failed: '失敗',
       skipped: 'スキップ',
@@ -1473,6 +1474,35 @@ export default {
       model: 'モデル',
       serialNumber: 'シリアルナンバー',
       pendingFiles: '保留中のファイル',
+      modeProxy: 'プロキシ',
+      modeProxyDesc: '実際のプリンターに中継',
+      descriptionProxy: 'スライサーのトラフィックを実際のプリンターに中継するプロキシを有効にし、任意のネットワーク経由でリモート印刷を可能にします。',
+      proxyingTo: '{{name}}にプロキシ中',
+      notActive: '非アクティブ',
+      targetPrinterTitle: 'ターゲットプリンター',
+      targetPrinterConfigured: 'プロキシターゲット設定済み',
+      targetPrinterNotConfigured: 'ターゲットプリンター未選択 - プロキシモードに必要です',
+      targetPrinterPlaceholder: 'プリンターを選択...',
+      targetPrinterHint: 'スライサーのトラフィックを中継するプリンターを選択します。プリンターはLANモードである必要があります。',
+      targetPrinterNoPrinters: 'プリンターが設定されていません。プロキシモードを使用するには、まずプリンターを追加してください。',
+      remoteInterfaceTitle: 'スライサーネットワークインターフェース',
+      remoteInterfaceConfigured: 'SSDPプロキシ有効',
+      remoteInterfaceOptional: 'オプション - ネットワーク間のSSDPディスカバリー用',
+      remoteInterfacePlaceholder: 'スライサーネットワークのインターフェースを選択...',
+      remoteInterfaceHint: 'スライサーに接続されているネットワークインターフェースを選択します。Bambu Studioでの自動プリンター検出を有効にします。',
+      targetPrinterRequired: '先にターゲットプリンターを選択してください',
+      ftpPort: 'FTPポート',
+      mqttPort: 'MQTTポート',
+      ftpConnections: 'FTP接続数',
+      mqttConnections: 'MQTT接続数',
+      targetPrinterStatus: 'ターゲットプリンター',
+      howItWorksProxy: '仕組み(プロキシモード)',
+      proxyStep1: 'ターゲットプリンターを選択(LANモードである必要があります)',
+      proxyStep2: 'クロスネットワーク時:スライサーネットワークインターフェースを選択',
+      proxyStep3: 'プロキシを有効化 - プリンターがSSDPでスライサー検出に表示されます',
+      proxyStep4: 'プリンターのアクセスコードで接続',
+      proxyStep5: '通常通り印刷 - トラフィックはBambuddyを経由して中継されます',
+      proxyStep6: 'カメラストリーミングにはNAT/IP転送が必要です(ドキュメント参照)',
     },
     enterNewCodeToChange: '新しいコードを入力して変更',
     enter8CharCode: '8文字のコードを入力',
@@ -1885,6 +1915,7 @@ export default {
     linkTo: 'リンク先...',
     printedCount: '{{count}}回印刷済み',
     addToQueue: 'キューに追加',
+    schedulePrint: '印刷をスケジュール',
     adding: '追加中...',
     folderCreated: 'フォルダを作成しました',
     folderDeleted: 'フォルダを削除しました',
@@ -3043,4 +3074,36 @@ export default {
     failedToCreateBackup: 'バックアップの作成に失敗しました',
     backupRestored: 'バックアップの復元が完了しました',
   },
+
+  // Model Viewer
+  modelViewer: {
+    openInSlicer: 'スライサーで開く',
+    tabs: {
+      model: '3Dモデル',
+      gcode: 'G-codeプレビュー',
+    },
+    notAvailable: '利用不可',
+    notSliced: '未スライス',
+    plates: 'プレート',
+    allPlates: '全プレート',
+    plateNumber: 'プレート {{number}}',
+    plateCount: '{{count}} プレート',
+    plateCount_other: '{{count}} プレート',
+    objectCount: '{{count}} オブジェクト',
+    objectCount_other: '{{count}} オブジェクト',
+    filamentCount: '{{count}} フィラメント',
+    filamentCount_other: '{{count}} フィラメント',
+    eta: '予想時間 {{minutes}} 分',
+    noPreview: 'このファイルのプレビューは利用できません',
+    pagination: {
+      pageOf: 'ページ {{current}} / {{total}}',
+      prev: '前へ',
+      next: '次へ',
+    },
+    errors: {
+      failedToLoad: 'ファイルの読み込みに失敗しました',
+      noMeshes: '3MFファイルにメッシュが見つかりません',
+      unsupportedFormat: 'サポートされていないファイル形式です',
+    },
+  },
 };

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

+ 183 - 54
frontend/src/pages/FileManagerPage.tsx

@@ -37,6 +37,8 @@ import {
   Pencil,
   Play,
   Image,
+  User,
+  Box,
 } from 'lucide-react';
 import { api } from '../api/client';
 import type {
@@ -51,6 +53,7 @@ import type {
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { PrintModal } from '../components/PrintModal';
+import { ModelViewerModal } from '../components/ModelViewerModal';
 import { useToast } from '../contexts/ToastContext';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { useAuth } from '../contexts/AuthContext';
@@ -429,6 +432,7 @@ interface UploadFile {
   status: 'pending' | 'uploading' | 'success' | 'error';
   error?: string;
   isZip?: boolean;
+  is3mf?: boolean;
   extractedCount?: number;
 }
 
@@ -469,6 +473,7 @@ function UploadModal({ folderId, onClose, onUploadComplete, t }: UploadModalProp
       file,
       status: 'pending',
       isZip: file.name.toLowerCase().endsWith('.zip'),
+      is3mf: file.name.toLowerCase().endsWith('.3mf'),
     }));
     setFiles((prev) => [...prev, ...uploadFiles]);
   };
@@ -479,12 +484,14 @@ function UploadModal({ folderId, onClose, onUploadComplete, t }: UploadModalProp
 
   const hasZipFiles = files.some((f) => f.isZip && f.status === 'pending');
   const hasStlFiles = files.some((f) => f.file.name.toLowerCase().endsWith('.stl') && f.status === 'pending');
+  const has3mfFiles = files.some((f) => f.is3mf && f.status === 'pending');
 
   const handleUpload = async () => {
     if (files.length === 0) return;
 
     setIsUploading(true);
 
+    // Handle all files with library upload (ZIP and regular files including .3mf)
     for (let i = 0; i < files.length; i++) {
       if (files[i].status !== 'pending') continue;
 
@@ -509,7 +516,7 @@ function UploadModal({ folderId, onClose, onUploadComplete, t }: UploadModalProp
             )
           );
         } else {
-          // Regular file upload
+          // Regular file upload (STL, .3mf, etc.) - .3mf files automatically get metadata extracted
           await api.uploadLibraryFile(files[i].file, folderId, generateStlThumbnails);
           setFiles((prev) =>
             prev.map((f, idx) => (idx === i ? { ...f, status: 'success' } : f))
@@ -609,6 +616,21 @@ function UploadModal({ folderId, onClose, onUploadComplete, t }: UploadModalProp
             </div>
           )}
 
+          {/* 3MF File Info - Advanced Extraction */}
+          {has3mfFiles && (
+            <div className="p-3 bg-purple-500/10 border border-purple-500/30 rounded-lg">
+              <div className="flex items-start gap-3">
+                <Printer className="w-5 h-5 text-purple-400 mt-0.5 flex-shrink-0" />
+                <div className="flex-1">
+                  <p className="text-sm text-purple-300 font-medium">{t('fileManager.threemfDetected')}</p>
+                  <p className="text-xs text-purple-300/70 mt-1">
+                    {t('fileManager.threemfExtractionInfo')}
+                  </p>
+                </div>
+              </div>
+            </div>
+          )}
+
           {/* STL Thumbnail Options - show for STL files or ZIP files (which may contain STLs) */}
           {(hasStlFiles || hasZipFiles) && (
             <div className="p-3 bg-bambu-green/10 border border-bambu-green/30 rounded-lg">
@@ -887,20 +909,22 @@ interface FileCardProps {
   onDownload: (id: number) => void;
   onAddToQueue?: (id: number) => void;
   onPrint?: (file: LibraryFileListItem) => void;
+  onPreview3d?: (file: LibraryFileListItem) => void;
   onRename?: (file: LibraryFileListItem) => void;
   onGenerateThumbnail?: (file: LibraryFileListItem) => void;
   thumbnailVersion?: number;
   hasPermission: (permission: Permission) => boolean;
   canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
+  authEnabled: boolean;
   t: TFunction;
 }
 
-function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename, onGenerateThumbnail, thumbnailVersion, hasPermission, canModify, t }: FileCardProps) {
+function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onPreview3d, onRename, onGenerateThumbnail, thumbnailVersion, hasPermission, canModify, authEnabled, t }: FileCardProps) {
   const [showActions, setShowActions] = useState(false);
 
   return (
     <div
-      className={`group relative bg-bambu-card rounded-lg border transition-all cursor-pointer overflow-hidden ${
+      className={`group relative bg-bambu-dark-secondary rounded-lg border transition-all cursor-pointer overflow-hidden ${
         isSelected
           ? 'border-bambu-green ring-1 ring-bambu-green'
           : 'border-bambu-dark-tertiary hover:border-bambu-green/50'
@@ -943,14 +967,21 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
             </span>
           )}
         </div>
+        {file.sliced_for_model && (
+          <div className="mt-1 text-xs text-bambu-gray flex items-center gap-1">
+            <Printer className="w-3 h-3" />
+            {file.sliced_for_model}
+          </div>
+        )}
         {file.print_count > 0 && (
           <div className="mt-1 text-xs text-bambu-green">
             {t('fileManager.printedCount', { count: file.print_count })}
           </div>
         )}
-        {file.created_by_username && (
-          <div className="mt-1 text-xs text-bambu-gray">
-            {t('fileManager.uploadedBy', { name: file.created_by_username })}
+        {authEnabled && file.created_by_username && (
+          <div className="mt-1 text-xs text-bambu-gray flex items-center gap-1">
+            <User className="w-3 h-3" />
+            {file.created_by_username}
           </div>
         )}
       </div>
@@ -990,7 +1021,20 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
                   title={!hasPermission('queue:create') ? t('fileManager.noPermissionAddToQueue') : undefined}
                 >
                   <Clock className="w-3.5 h-3.5" />
-                  {t('fileManager.addToQueue')}
+                  {t('fileManager.schedulePrint')}
+                </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
@@ -1062,7 +1106,7 @@ export function FileManagerPage() {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
-  const { hasPermission, hasAnyPermission, canModify } = useAuth();
+  const { hasPermission, hasAnyPermission, canModify, authEnabled } = useAuth();
   const [searchParams] = useSearchParams();
 
   // Read folder ID from URL query parameter
@@ -1079,8 +1123,10 @@ export function FileManagerPage() {
   const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'file' | 'folder' | 'bulk'; id: number; count?: number } | null>(null);
   const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);
   const [printMultiFile, setPrintMultiFile] = 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 [thumbnailVersions, setThumbnailVersions] = useState<Record<number, number>>({});
+  const [viewerFile, setViewerFile] = useState<LibraryFileListItem | null>(null);
   const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
     return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
   });
@@ -1137,6 +1183,7 @@ export function FileManagerPage() {
   // Filter and sort state (persist sort preferences to localStorage)
   const [searchQuery, setSearchQuery] = useState('');
   const [filterType, setFilterType] = useState<string>('all');
+  const [filterUsername, setFilterUsername] = useState('');
   const [sortField, setSortField] = useState<SortField>(() => {
     const saved = localStorage.getItem('library-sort-field');
     return (saved as SortField) || 'name';
@@ -1178,6 +1225,12 @@ export function FileManagerPage() {
     queryFn: () => api.getLibraryStats(),
   });
 
+  // Get users for the username filter autocomplete
+  const { data: users } = useQuery({
+    queryKey: ['users'],
+    queryFn: () => api.getUsers(),
+  });
+
   // Get unique file types for filter dropdown
   const fileTypes = useMemo(() => {
     if (!files) return [];
@@ -1206,6 +1259,14 @@ export function FileManagerPage() {
       result = result.filter((f) => f.file_type === filterType);
     }
 
+    // Apply username filter
+    if (filterUsername.trim()) {
+      const query = filterUsername.toLowerCase();
+      result = result.filter(
+        (f) => f.created_by_username && f.created_by_username.toLowerCase().includes(query)
+      );
+    }
+
     // Apply sorting
     result.sort((a, b) => {
       let comparison = 0;
@@ -1230,7 +1291,7 @@ export function FileManagerPage() {
     });
 
     return result;
-  }, [files, searchQuery, filterType, sortField, sortDirection]);
+  }, [files, searchQuery, filterType, filterUsername, sortField, sortDirection]);
 
   // Check if disk space is low
   const isDiskSpaceLow = useMemo(() => {
@@ -1328,31 +1389,6 @@ export function FileManagerPage() {
     onError: (error: Error) => showToast(error.message, 'error'),
   });
 
-  const addToQueueMutation = useMutation({
-    mutationFn: (fileIds: number[]) => api.addLibraryFilesToQueue(fileIds),
-    onSuccess: (result) => {
-      queryClient.invalidateQueries({ queryKey: ['library-files'] });
-      queryClient.invalidateQueries({ queryKey: ['queue'] });
-      queryClient.invalidateQueries({ queryKey: ['archives'] }); // Archives are created when adding to queue
-      setSelectedFiles([]);
-
-      if (result.added.length > 0 && result.errors.length === 0) {
-        showToast(
-          t('fileManager.toast.addedToQueue', { count: result.added.length }),
-          'success'
-        );
-      } else if (result.added.length > 0 && result.errors.length > 0) {
-        showToast(
-          t('fileManager.toast.addedToQueuePartial', { added: result.added.length, failed: result.errors.length }),
-          'success'
-        );
-      } else {
-        showToast(t('fileManager.toast.failedToAddToQueue', { error: result.errors[0]?.error || 'Unknown error' }), 'error');
-      }
-    },
-    onError: (error: Error) => showToast(error.message, 'error'),
-  });
-
   const renameFileMutation = useMutation({
     mutationFn: ({ id, filename }: { id: number; filename: string }) =>
       api.updateLibraryFile(id, { filename }),
@@ -1512,7 +1548,7 @@ export function FileManagerPage() {
             <button
               onClick={() => handleViewModeChange('grid')}
               className={`p-1.5 rounded transition-colors ${
-                viewMode === 'grid' ? 'bg-bambu-card text-white' : 'text-bambu-gray hover:text-white'
+                viewMode === 'grid' ? 'bg-bambu-dark-secondary text-white' : 'text-bambu-gray hover:text-white'
               }`}
               title={t('fileManager.gridView')}
             >
@@ -1521,7 +1557,7 @@ export function FileManagerPage() {
             <button
               onClick={() => handleViewModeChange('list')}
               className={`p-1.5 rounded transition-colors ${
-                viewMode === 'list' ? 'bg-bambu-card text-white' : 'text-bambu-gray hover:text-white'
+                viewMode === 'list' ? 'bg-bambu-dark-secondary text-white' : 'text-bambu-gray hover:text-white'
               }`}
               title={t('fileManager.listView')}
             >
@@ -1576,7 +1612,7 @@ export function FileManagerPage() {
 
       {/* Stats bar */}
       {stats && (
-        <div className="flex flex-wrap items-center gap-3 sm:gap-6 mb-6 p-3 bg-bambu-card rounded-lg border border-bambu-dark-tertiary">
+        <div className="flex flex-wrap items-center gap-3 sm:gap-6 mb-6 p-3 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary">
           <div className="flex items-center gap-2 text-sm">
             <File className="w-4 h-4 text-bambu-green" />
             <span className="text-bambu-gray">{t('fileManager.files')}:</span>
@@ -1608,7 +1644,7 @@ export function FileManagerPage() {
           <select
             value={selectedFolderId ?? ''}
             onChange={(e) => setSelectedFolderId(e.target.value ? parseInt(e.target.value, 10) : null)}
-            className="w-full bg-bambu-card border border-bambu-dark-tertiary rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-bambu-green"
+            className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-bambu-green"
           >
             <option value="">📁 {t('fileManager.allFiles')}</option>
             {folders && (() => {
@@ -1635,7 +1671,7 @@ export function FileManagerPage() {
         {/* Folder sidebar - resizable, hidden on mobile */}
         <div
           ref={sidebarRef}
-          className="hidden lg:flex flex-shrink-0 bg-bambu-card rounded-lg border border-bambu-dark-tertiary overflow-hidden flex-col relative"
+          className="hidden lg:flex flex-shrink-0 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary overflow-hidden flex-col relative"
           style={{ width: `${sidebarWidth}px` }}
         >
           {/* Resize handle - drag to resize, double-click to reset */}
@@ -1714,7 +1750,7 @@ export function FileManagerPage() {
         <div className="flex-1 flex flex-col min-w-0 min-h-0">
           {/* Search, Filter, Sort toolbar - sticky on mobile for easier access */}
           {files && files.length > 0 && (
-            <div className="flex flex-wrap items-center gap-2 sm:gap-3 mb-4 p-2 sm:p-3 bg-bambu-card rounded-lg border border-bambu-dark-tertiary sticky top-0 z-10 lg:static">
+            <div className="flex flex-wrap items-center gap-2 sm:gap-3 mb-4 p-2 sm:p-3 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary sticky top-0 z-10 lg:static">
               {/* Search */}
               <div className="relative w-full sm:w-auto sm:flex-1 sm:max-w-xs">
                 <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
@@ -1744,6 +1780,34 @@ export function FileManagerPage() {
                 </select>
               </div>
 
+              {/* Username filter with autocomplete - only show when auth is enabled */}
+              {authEnabled && (
+                <div className="relative">
+                  <input
+                    type="text"
+                    placeholder={t('fileManager.filterByUser', { defaultValue: 'Filter by user' })}
+                    value={filterUsername}
+                    onChange={(e) => setFilterUsername(e.target.value)}
+                    list="usernames-list"
+                    className={`w-32 sm:w-40 px-2 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green ${filterUsername ? 'pr-7' : ''}`}
+                    style={filterUsername ? { WebkitAppearance: 'none', MozAppearance: 'textfield' } : undefined}
+                  />
+                  {filterUsername && (
+                    <button
+                      onClick={() => setFilterUsername('')}
+                      className="absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white z-10"
+                    >
+                      <X className="w-3 h-3" />
+                    </button>
+                  )}
+                  <datalist id="usernames-list">
+                    {users?.map((user) => (
+                      <option key={user.id} value={user.username} />
+                    ))}
+                  </datalist>
+                </div>
+              )}
+
               {/* Sort */}
               <div className="flex items-center gap-2">
                 <select
@@ -1779,7 +1843,7 @@ export function FileManagerPage() {
               </div>
 
               {/* Results count */}
-              {(searchQuery || filterType !== 'all') && (
+              {(searchQuery || filterType !== 'all' || filterUsername) && (
                 <span className="text-sm text-bambu-gray hidden sm:inline">
                   {t('fileManager.resultsCount', { showing: filteredAndSortedFiles.length, total: files.length })}
                 </span>
@@ -1789,7 +1853,7 @@ export function FileManagerPage() {
 
           {/* Selection toolbar - sticky on mobile below search bar */}
           {filteredAndSortedFiles.length > 0 && (
-            <div className="flex flex-wrap items-center gap-2 mb-4 p-2 bg-bambu-card rounded-lg border border-bambu-dark-tertiary sticky top-[52px] z-10 lg:static">
+            <div className="flex flex-wrap items-center gap-2 mb-4 p-2 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary sticky top-[52px] z-10 lg:static">
               {/* Select all / Deselect all */}
               {selectedFiles.length === filteredAndSortedFiles.length && selectedFiles.length > 0 ? (
                 <Button
@@ -1830,16 +1894,19 @@ export function FileManagerPage() {
                         <span className="hidden sm:inline">{t('common.print')}</span>
                       </Button>
                     )}
-                    {selectedSlicedFiles.length > 0 && (
+                    {selectedSlicedFiles.length === 1 && (
                       <Button
-                        variant={selectedSlicedFiles.length === 1 ? 'secondary' : 'primary'}
+                        variant="secondary"
                         size="sm"
-                        onClick={() => addToQueueMutation.mutate(selectedSlicedFiles.map(f => f.id))}
-                        disabled={addToQueueMutation.isPending || !hasPermission('queue:create')}
+                        // Note: Schedule dialog (PrintModal) is designed for single file at a time
+                        // but supports scheduling to multiple printers. This provides more control
+                        // over scheduling options compared to the previous bulk queue mutation.
+                        onClick={() => setScheduleFile(selectedSlicedFiles[0])}
+                        disabled={!hasPermission('queue:create')}
                         title={!hasPermission('queue:create') ? t('fileManager.noPermissionAddToQueue') : undefined}
                       >
                         <Clock className="w-4 h-4 sm:mr-1" />
-                        <span className="hidden sm:inline">{addToQueueMutation.isPending ? t('fileManager.adding') : `${t('fileManager.addToQueue')}${selectedSlicedFiles.length < selectedFiles.length ? ` (${selectedSlicedFiles.length})` : ''}`}</span>
+                        <span className="hidden sm:inline">{t('fileManager.schedulePrint')}</span>
                       </Button>
                     )}
                     <Button
@@ -1938,24 +2005,30 @@ export function FileManagerPage() {
                     onSelect={handleFileSelect}
                     onDelete={(id) => setDeleteConfirm({ type: 'file', id })}
                     onDownload={handleDownload}
-                    onAddToQueue={(id) => addToQueueMutation.mutate([id])}
+                    onAddToQueue={(id) => {
+                      const file = files?.find(f => f.id === id);
+                      if (file) setScheduleFile(file);
+                    }}
                     onPrint={setPrintFile}
+                    onPreview3d={setViewerFile}
                     onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
                     onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
                     thumbnailVersion={thumbnailVersions[file.id]}
                     hasPermission={hasPermission}
                     canModify={canModify}
+                    authEnabled={authEnabled}
                   />
                 ))}
               </div>
             </div>
           ) : (
             <div className="flex-1 lg:overflow-y-auto">
-              <div className="bg-bambu-card rounded-lg border border-bambu-dark-tertiary overflow-hidden">
+              <div className="bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary overflow-hidden">
                 {/* List header - hidden on mobile, show simplified on small screens */}
-                <div className="hidden sm:grid grid-cols-[auto_1fr_100px_100px_100px_80px] gap-4 px-4 py-2 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary text-xs text-bambu-gray font-medium">
+                <div className={`hidden sm:grid ${authEnabled ? 'grid-cols-[auto_1fr_120px_100px_100px_100px_80px]' : 'grid-cols-[auto_1fr_100px_100px_100px_80px]'} gap-4 px-4 py-2 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary text-xs text-bambu-gray font-medium`}>
                   <div className="w-6" />
                   <div>{t('common.name')}</div>
+                  {authEnabled && <div>{t('fileManager.uploadedBy', { defaultValue: 'Uploaded By' })}</div>}
                   <div>{t('common.type')}</div>
                   <div>{t('fileManager.size')}</div>
                   <div>{t('fileManager.prints')}</div>
@@ -1965,7 +2038,7 @@ export function FileManagerPage() {
                 {filteredAndSortedFiles.map((file) => (
                   <div
                     key={file.id}
-                    className={`grid grid-cols-[auto_1fr_100px_100px_100px_80px] gap-4 px-4 py-3 items-center border-b border-bambu-dark-tertiary last:border-b-0 cursor-pointer hover:bg-bambu-dark/50 transition-colors ${
+                    className={`grid ${authEnabled ? 'grid-cols-[auto_1fr_120px_100px_100px_100px_80px]' : 'grid-cols-[auto_1fr_100px_100px_100px_80px]'} gap-4 px-4 py-3 items-center border-b border-bambu-dark-tertiary last:border-b-0 cursor-pointer hover:bg-bambu-dark/50 transition-colors ${
                       selectedFiles.includes(file.id) ? 'bg-bambu-green/10' : ''
                     }`}
                     onClick={() => handleFileSelect(file.id)}
@@ -2011,6 +2084,19 @@ export function FileManagerPage() {
                         <div className="text-sm text-white truncate">{file.print_name || file.filename}</div>
                       </div>
                     </div>
+                    {/* Uploaded By - only show when auth is enabled */}
+                    {authEnabled && (
+                      <div className="text-sm text-bambu-gray flex items-center gap-1">
+                        {file.created_by_username ? (
+                          <>
+                            <User className="w-3 h-3" />
+                            <span className="truncate">{file.created_by_username}</span>
+                          </>
+                        ) : (
+                          '-'
+                        )}
+                      </div>
+                    )}
                     {/* Type */}
                     <div>
                       <span className={`text-xs px-1.5 py-0.5 rounded font-medium ${
@@ -2043,19 +2129,37 @@ export function FileManagerPage() {
                             <Printer className="w-4 h-4" />
                           </button>
                           <button
-                            onClick={() => hasPermission('queue:create') && addToQueueMutation.mutate([file.id])}
+                            onClick={() => {
+                              if (hasPermission('queue:create')) {
+                                setScheduleFile(file);
+                              }
+                            }}
                             className={`p-1.5 rounded transition-colors ${
                               hasPermission('queue:create')
                                 ? 'hover:bg-bambu-dark text-bambu-gray hover:text-white'
                                 : 'text-bambu-gray/50 cursor-not-allowed'
                             }`}
-                            title={hasPermission('queue:create') ? t('fileManager.addToQueue') : t('fileManager.noPermissionAddToQueue')}
-                            disabled={addToQueueMutation.isPending || !hasPermission('queue:create')}
+                            title={hasPermission('queue:create') ? t('fileManager.schedulePrint') : t('fileManager.noPermissionAddToQueue')}
+                            disabled={!hasPermission('queue:create')}
                           >
                             <Clock className="w-4 h-4" />
                           </button>
                         </>
                       )}
+                      {(file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'stl') && (
+                        <button
+                          onClick={() => hasPermission('library:read') && setViewerFile(file)}
+                          className={`p-1.5 rounded transition-colors ${
+                            hasPermission('library:read')
+                              ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
+                              : 'text-bambu-gray/50 cursor-not-allowed'
+                          }`}
+                          title={hasPermission('library:read') ? '3D Preview' : 'You do not have permission to preview files'}
+                          disabled={!hasPermission('library:read')}
+                        >
+                          <Box className="w-4 h-4" />
+                        </button>
+                      )}
                       <button
                         onClick={() => hasPermission('library:read') && handleDownload(file.id)}
                         className={`p-1.5 rounded transition-colors ${
@@ -2211,6 +2315,31 @@ export function FileManagerPage() {
         />
       )}
 
+      {scheduleFile && (
+        <PrintModal
+          mode="add-to-queue"
+          libraryFileId={scheduleFile.id}
+          archiveName={scheduleFile.print_name || scheduleFile.filename}
+          onClose={() => setScheduleFile(null)}
+          onSuccess={() => {
+            setScheduleFile(null);
+            setSelectedFiles([]);
+            queryClient.invalidateQueries({ queryKey: ['library-files'] });
+            queryClient.invalidateQueries({ queryKey: ['queue'] });
+            queryClient.invalidateQueries({ queryKey: ['archives'] });
+          }}
+        />
+      )}
+
+      {viewerFile && (
+        <ModelViewerModal
+          libraryFileId={viewerFile.id}
+          title={viewerFile.print_name || viewerFile.filename}
+          fileType={viewerFile.file_type}
+          onClose={() => setViewerFile(null)}
+        />
+      )}
+
       {renameItem && (
         <RenameModal
           type={renameItem.type}

+ 1 - 1
frontend/src/pages/PrintersPage.tsx

@@ -1603,7 +1603,7 @@ function PrinterCard({
                 <MoreVertical className="w-4 h-4" />
               </Button>
               {showMenu && (
-                <div className="absolute right-0 mt-2 w-48 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-10">
+                <div className="absolute right-0 mt-2 w-48 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-20">
                   <button
                     className={`w-full px-4 py-2 text-left text-sm flex items-center gap-2 ${
                       hasPermission('printers:update')

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

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

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

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

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

@@ -0,0 +1,42 @@
+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[];
+  object_count?: number;
+  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;
+}

+ 45 - 18
frontend/src/utils/date.ts

@@ -94,9 +94,22 @@ export function formatTimeInput(date: Date, timeFormat: TimeFormat = 'system'):
   }
 }
 
+/**
+ * Split a date string by common separators (/, ., -).
+ */
+function splitDateParts(value: string): string[] | null {
+  // Try common separators: /, ., -
+  for (const sep of ['/', '.', '-']) {
+    const parts = value.split(sep);
+    if (parts.length === 3) return parts;
+  }
+  return null;
+}
+
 /**
  * Parse a date string based on format setting.
  * Returns null if parsing fails.
+ * Supports common separators: / . -
  */
 export function parseDateInput(value: string, dateFormat: DateFormat = 'system'): Date | null {
   if (!value) return null;
@@ -106,27 +119,27 @@ export function parseDateInput(value: string, dateFormat: DateFormat = 'system')
   try {
     switch (dateFormat) {
       case 'us': {
-        // MM/DD/YYYY
-        const parts = value.split('/');
-        if (parts.length !== 3) return null;
+        // MM/DD/YYYY (also accepts . and - separators)
+        const parts = splitDateParts(value);
+        if (!parts) return null;
         month = parseInt(parts[0], 10);
         day = parseInt(parts[1], 10);
         year = parseInt(parts[2], 10);
         break;
       }
       case 'eu': {
-        // DD/MM/YYYY
-        const parts = value.split('/');
-        if (parts.length !== 3) return null;
+        // DD/MM/YYYY (also accepts . and - separators)
+        const parts = splitDateParts(value);
+        if (!parts) return null;
         day = parseInt(parts[0], 10);
         month = parseInt(parts[1], 10);
         year = parseInt(parts[2], 10);
         break;
       }
       case 'iso': {
-        // YYYY-MM-DD
-        const parts = value.split('-');
-        if (parts.length !== 3) return null;
+        // YYYY-MM-DD (also accepts . and / separators)
+        const parts = splitDateParts(value);
+        if (!parts) return null;
         year = parseInt(parts[0], 10);
         month = parseInt(parts[1], 10);
         day = parseInt(parts[2], 10);
@@ -134,15 +147,29 @@ export function parseDateInput(value: string, dateFormat: DateFormat = 'system')
       }
       case 'system':
       default: {
-        // Try common formats
-        const date = new Date(value);
-        if (!isNaN(date.getTime())) return date;
-        // Try EU format
-        const euParts = value.split('/');
-        if (euParts.length === 3) {
-          day = parseInt(euParts[0], 10);
-          month = parseInt(euParts[1], 10);
-          year = parseInt(euParts[2], 10);
+        // Detect system format and parse accordingly
+        const testDate = new Date(2000, 11, 31); // Dec 31, 2000
+        const formatted = testDate.toLocaleDateString();
+        const parts = splitDateParts(value);
+
+        if (parts) {
+          // Detect format from system locale
+          if (formatted.startsWith('12')) {
+            // US format: MM/DD/YYYY
+            month = parseInt(parts[0], 10);
+            day = parseInt(parts[1], 10);
+            year = parseInt(parts[2], 10);
+          } else if (formatted.startsWith('31')) {
+            // EU format: DD/MM/YYYY
+            day = parseInt(parts[0], 10);
+            month = parseInt(parts[1], 10);
+            year = parseInt(parts[2], 10);
+          } else {
+            // ISO format: YYYY-MM-DD
+            year = parseInt(parts[0], 10);
+            month = parseInt(parts[1], 10);
+            day = parseInt(parts[2], 10);
+          }
           break;
         }
         return null;

+ 0 - 7
frontend/vitest.config.ts

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

+ 520 - 0
install/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

BIN
mockup/icons/ams-ht.png


BIN
mockup/icons/ams-ht.xcf


+ 0 - 1
mockup/icons/ams-settings.svg

@@ -1 +0,0 @@
-<svg fill="none" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><g clip-rule="evenodd" fill="#000" fill-rule="evenodd"><path d="m2.25 6c0-.41421.33579-.75.75-.75h3.5c.41421 0 .75.33579.75.75s-.33579.75-.75.75h-3.5c-.41421 0-.75-.33579-.75-.75zm8.5 0c0-.41421.3358-.75.75-.75h9.5c.4142 0 .75.33579.75.75s-.3358.75-.75.75h-9.5c-.4142 0-.75-.33579-.75-.75z"/><path d="m9 4.25c-.9665 0-1.75.7835-1.75 1.75s.7835 1.75 1.75 1.75 1.75-.7835 1.75-1.75-.7835-1.75-1.75-1.75zm-3.25 1.75c0-1.79493 1.45507-3.25 3.25-3.25 1.7949 0 3.25 1.45507 3.25 3.25s-1.4551 3.25-3.25 3.25c-1.79493 0-3.25-1.45507-3.25-3.25z"/><path d="m21.75 12c0-.4142-.3358-.75-.75-.75h-2c-.4142 0-.75.3358-.75.75s.3358.75.75.75h2c.4142 0 .75-.3358.75-.75zm-7 0c0-.4142-.3358-.75-.75-.75h-5c-.41421 0-.75.3358-.75.75s.33579.75.75.75h5c.4142 0 .75-.3358.75-.75zm-9 0c0-.4142-.33579-.75-.75-.75h-2c-.41421 0-.75.3358-.75.75s.33579.75.75.75h2c.41421 0 .75-.3358.75-.75z"/><path d="m16.5 10.25c.9665 0 1.75.7835 1.75 1.75s-.7835 1.75-1.75 1.75-1.75-.7835-1.75-1.75.7835-1.75 1.75-1.75zm3.25 1.75c0-1.7949-1.4551-3.25-3.25-3.25s-3.25 1.4551-3.25 3.25 1.4551 3.25 3.25 3.25 3.25-1.4551 3.25-3.25z"/><path d="m2.25 18c0-.4142.33579-.75.75-.75h5c.41421 0 .75.3358.75.75s-.33579.75-.75.75h-5c-.41421 0-.75-.3358-.75-.75zm10 0c0-.4142.3358-.75.75-.75h8c.4142 0 .75.3358.75.75s-.3358.75-.75.75h-8c-.4142 0-.75-.3358-.75-.75z"/><path d="m10.5 16.25c-.9665 0-1.75.7835-1.75 1.75s.7835 1.75 1.75 1.75 1.75-.7835 1.75-1.75-.7835-1.75-1.75-1.75zm-3.25 1.75c0-1.7949 1.45507-3.25 3.25-3.25 1.7949 0 3.25 1.4551 3.25 3.25s-1.4551 3.25-3.25 3.25c-1.79493 0-3.25-1.4551-3.25-3.25z"/></g></svg>

BIN
mockup/icons/ams.png


BIN
mockup/icons/ams.xcf


File diff suppressed because it is too large
+ 0 - 0
mockup/icons/chamber.svg


BIN
mockup/icons/dual-extruder.png


BIN
mockup/icons/dual-extruder.xcf


BIN
mockup/icons/extruder-left-right.png


BIN
mockup/icons/extruder-left-right.xcf


+ 0 - 51
mockup/icons/eye.svg

@@ -1,51 +0,0 @@
-<?xml version="1.0" encoding="iso-8859-1"?>
-<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
-<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
-	 viewBox="0 0 511.999 511.999" style="enable-background:new 0 0 511.999 511.999;" xml:space="preserve">
-<g>
-	<g>
-		<path d="M508.745,246.041c-4.574-6.257-113.557-153.206-252.748-153.206S7.818,239.784,3.249,246.035
-			c-4.332,5.936-4.332,13.987,0,19.923c4.569,6.257,113.557,153.206,252.748,153.206s248.174-146.95,252.748-153.201
-			C513.083,260.028,513.083,251.971,508.745,246.041z M255.997,385.406c-102.529,0-191.33-97.533-217.617-129.418
-			c26.253-31.913,114.868-129.395,217.617-129.395c102.524,0,191.319,97.516,217.617,129.418
-			C447.361,287.923,358.746,385.406,255.997,385.406z"/>
-	</g>
-</g>
-<g>
-	<g>
-		<path d="M255.997,154.725c-55.842,0-101.275,45.433-101.275,101.275s45.433,101.275,101.275,101.275
-			s101.275-45.433,101.275-101.275S311.839,154.725,255.997,154.725z M255.997,323.516c-37.23,0-67.516-30.287-67.516-67.516
-			s30.287-67.516,67.516-67.516s67.516,30.287,67.516,67.516S293.227,323.516,255.997,323.516z"/>
-	</g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-</svg>

+ 0 - 1
mockup/icons/heatbed.svg

@@ -1 +0,0 @@
-<svg id="Layer_1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" data-name="Layer 1"><g fill="rgb(0,0,0)"><path d="m6.3 3.9c.54-.4 1.2-.9 1.2-1.9 0-.28-.22-.5-.5-.5s-.5.22-.5.5c0 .48-.29.71-.8 1.1-.53.4-1.2.9-1.2 1.9s.67 1.5 1.2 1.9c.51.38.8.62.8 1.1s-.29.72-.8 1.1c-.54.4-1.2.9-1.2 1.9 0 .28.22.5.5.5s.5-.22.5-.5c0-.48.29-.72.8-1.1.54-.4 1.2-.9 1.2-1.9s-.67-1.5-1.2-1.9c-.51-.38-.8-.62-.8-1.1s.29-.72.8-1.1z"/><path d="m12.3 3.9c.54-.4 1.2-.9 1.2-1.9 0-.28-.22-.5-.5-.5s-.5.22-.5.5c0 .48-.29.71-.8 1.1-.53.4-1.2.9-1.2 1.9s.67 1.5 1.2 1.9c.51.38.8.62.8 1.1s-.29.72-.8 1.1c-.54.4-1.2.9-1.2 1.9 0 .28.22.5.5.5s.5-.22.5-.5c0-.48.29-.72.8-1.1.54-.4 1.2-.9 1.2-1.9s-.67-1.5-1.2-1.9c-.51-.38-.8-.62-.8-1.1s.29-.72.8-1.1z"/><path d="m18.3 3.9c.54-.4 1.2-.9 1.2-1.9 0-.28-.22-.5-.5-.5s-.5.22-.5.5c0 .48-.29.71-.8 1.1-.53.4-1.2.9-1.2 1.9s.67 1.5 1.2 1.9c.51.38.8.62.8 1.1s-.29.72-.8 1.1c-.54.4-1.2.9-1.2 1.9 0 .28.22.5.5.5s.5-.22.5-.5c0-.48.29-.72.8-1.1.54-.4 1.2-.9 1.2-1.9s-.67-1.5-1.2-1.9c-.51-.38-.8-.62-.8-1.1s.29-.72.8-1.1z"/><path d="m22 13.5c-1.07 0-1.61.65-2.05 1.18-.44.52-.71.82-1.29.82s-.85-.3-1.29-.82c-.44-.53-.98-1.18-2.05-1.18s-1.61.65-2.05 1.18c-.44.52-.71.82-1.28.82s-.85-.3-1.28-.82c-.44-.53-.98-1.18-2.05-1.18s-1.61.65-2.05 1.18c-.44.52-.71.82-1.28.82s-.84-.3-1.28-.82c-.44-.53-.98-1.18-2.05-1.18-.28 0-.5.22-.5.5s.22.5.5.5c.57 0 .84.3 1.28.82.44.53.98 1.18 2.05 1.18s1.61-.65 2.05-1.18c.44-.52.71-.82 1.28-.82s.85.3 1.28.82c.44.53.98 1.18 2.05 1.18s1.61-.65 2.05-1.18c.44-.52.71-.82 1.28-.82s.85.3 1.29.82c.44.53.98 1.18 2.05 1.18s1.61-.65 2.05-1.18c.44-.52.71-.82 1.29-.82.28 0 .5-.22.5-.5s-.22-.5-.5-.5z"/><path d="m21 18.5h-18c-.83 0-1.5.67-1.5 1.5v1c0 .83.67 1.5 1.5 1.5h18c.83 0 1.5-.67 1.5-1.5v-1c0-.83-.67-1.5-1.5-1.5zm.5 2.5c0 .28-.22.5-.5.5h-18c-.28 0-.5-.22-.5-.5v-1c0-.28.22-.5.5-.5h18c.28 0 .5.22.5.5z"/></g></svg>

+ 0 - 44
mockup/icons/home.svg

@@ -1,44 +0,0 @@
-<?xml version="1.0" encoding="iso-8859-1"?>
-<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
-<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
-	 viewBox="0 0 476.912 476.912" style="enable-background:new 0 0 476.912 476.912;" xml:space="preserve">
-<g>
-	<g>
-		<path d="M461.776,209.408L249.568,4.52c-6.182-6.026-16.042-6.026-22.224,0L15.144,209.4c-3.124,3.015-4.888,7.17-4.888,11.512
-			c0,8.837,7.164,16,16,16h28.2v224c0,8.837,7.163,16,16,16h112c8.837,0,16-7.163,16-16v-128h80v128c0,8.837,7.163,16,16,16h112
-			c8.837,0,16-7.163,16-16v-224h28.2c4.338,0,8.489-1.761,11.504-4.88C468.301,225.678,468.129,215.549,461.776,209.408z
-			 M422.456,220.912c-8.837,0-16,7.163-16,16v224h-112v-128c0-8.837-7.163-16-16-16h-80c-8.837,0-16,7.163-16,16v128h-112v-224
-			c0-8.837-7.163-16-16-16h-28.2l212.2-204.88l212.28,204.88H422.456z"/>
-	</g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-</svg>

+ 0 - 1
mockup/icons/hotend.svg

@@ -1 +0,0 @@
-<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><g id="Front_Heater" data-name="Front Heater"><path d="m33.14 29h2.86l-4-7-4 7h3l1.5 3.7a8.38 8.38 0 0 1 -.92 8l-.67.82a10.43 10.43 0 0 0 -.29 11.74 1 1 0 0 0 .82.42 1 1 0 0 0 .58-.19 1 1 0 0 0 .23-1.39 8.4 8.4 0 0 1 .21-9.32l.67-.82a10.32 10.32 0 0 0 1.22-10z"/><path d="m23.22 29h2.78l-4-7-4 7h3.06l1.5 3.7a8.35 8.35 0 0 1 -.92 8l-.67.82a10.43 10.43 0 0 0 -.29 11.74 1 1 0 0 0 .82.42 1 1 0 0 0 .58-.19 1 1 0 0 0 .24-1.39 8.4 8.4 0 0 1 .2-9.32l.67-.82a10.32 10.32 0 0 0 1.22-10z"/><path d="m57.88 20.09a3 3 0 0 0 -1.49-1.84l-7.73-4.1a35.48 35.48 0 0 0 -33.32 0l-7.72 4.1a3 3 0 0 0 -1.22 4.2l9.72 17a3 3 0 0 0 2.6 1.55 3 3 0 0 0 1.39-.34 1 1 0 0 0 .42-1.35 1 1 0 0 0 -1.34-.42 1 1 0 0 1 -1.33-.39l-9.72-17a1 1 0 0 1 -.1-.81 1 1 0 0 1 .52-.69l7.72-4.1a33.46 33.46 0 0 1 31.44 0l7.73 4.1a1 1 0 0 1 .51.63 1 1 0 0 1 -.1.81l-9.72 17a1 1 0 0 1 -1.33.39l-.2-.11a10.82 10.82 0 0 0 -.36-6.72l-1.19-3h2.94l-4-7-4 7h2.9l1.5 3.7a8.38 8.38 0 0 1 -.93 8l-.66.82a10.43 10.43 0 0 0 -.29 11.74 1 1 0 0 0 .82.42 1 1 0 0 0 .58-.19 1 1 0 0 0 .23-1.39 8.4 8.4 0 0 1 .21-9.32l.64-.78a8.27 8.27 0 0 0 .84-1.3 3 3 0 0 0 4-1.17l9.72-17a3 3 0 0 0 .32-2.44z"/></g></svg>

BIN
mockup/icons/jogpad.png


File diff suppressed because it is too large
+ 0 - 5
mockup/icons/jogpad.svg


BIN
mockup/icons/jogpad.xcf


+ 0 - 1
mockup/icons/lamp.svg

@@ -1 +0,0 @@
-<svg id="Ecommerce" enable-background="new 0 0 48 48" height="512" viewBox="0 0 48 48" width="512" xmlns="http://www.w3.org/2000/svg"><g><path d="m25 7.03c0-.552-.447-1-1-1-5.514 0-10 4.486-10 10 0 .552.447 1 1 1s1-.448 1-1c0-4.411 3.589-8 8-8 .553 0 1-.448 1-1z"/><path d="m22.246 45.79h1.754 1.754c1.652 0 2.999-1.345 3-3v-1.099c1.032-.475 1.755-1.512 1.755-2.721v-3.72c.176-.382.281-.802.281-1.25 0-1.016.226-2 .671-2.927.441-.919 1.086-1.752 1.864-2.409 3.746-3.165 5.709-7.989 5.25-12.909-.7-7.375-6.813-13.189-14.265-13.525l-.31-.01-.354.011c-7.408.335-13.521 6.149-14.222 13.526-.458 4.917 1.505 9.742 5.251 12.906.778.658 1.423 1.491 1.864 2.41.445.927.671 1.911.671 2.927 0 .447.105.868.281 1.25v3.721c0 1.209.722 2.247 1.755 2.721v1.1c.001 1.653 1.348 2.998 3 2.998zm4.508-3c0 .552-.449 1-1 1h-1.754-1.754c-.551 0-1-.449-1-1v-.82h2.754 2.754zm1.755-3.819c0 .551-.448 1-1 1h-3.509-3.509c-.552 0-1-.449-1-1v-2.068c.232.058.47.097.719.097h3.79 3.79c.249 0 .487-.039.719-.097zm-9.299-4.971c0-1.318-.292-2.595-.868-3.793-.563-1.171-1.385-2.233-2.376-3.071-3.247-2.742-4.948-6.926-4.551-11.191.607-6.389 5.903-11.426 12.275-11.715l.31-.01.265.009c6.417.29 11.713 5.327 12.319 11.714.398 4.267-1.303 8.451-4.55 11.193-.991.838-1.813 1.9-2.376 3.071-.576 1.198-.868 2.475-.868 3.793 0 .551-.448 1-1 1h-3.79-3.79c-.552 0-1-.449-1-1z"/></g></svg>

+ 0 - 1
mockup/icons/micro-sd.svg

@@ -1 +0,0 @@
-<svg id="Capa_1" enable-background="new 0 0 512 512" height="512" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg"><g><path d="m379.887 128.204h23.896c2.399 0 4.345-1.945 4.345-4.345v-69.514c0-2.4-1.945-4.345-4.345-4.345h-23.896c-2.4 0-4.345 1.945-4.345 4.345v69.515c0 2.399 1.945 4.344 4.345 4.344z"/><path d="m325.578 128.204h23.896c2.4 0 4.345-1.945 4.345-4.345v-69.514c0-2.4-1.945-4.345-4.345-4.345h-23.896c-2.399 0-4.345 1.945-4.345 4.345v69.515c0 2.399 1.946 4.344 4.345 4.344z"/><path d="m271.27 128.204h23.896c2.399 0 4.345-1.945 4.345-4.345v-69.514c0-2.4-1.945-4.345-4.345-4.345h-23.896c-2.399 0-4.344 1.945-4.344 4.345v69.515c-.001 2.399 1.944 4.344 4.344 4.344z"/><path d="m216.961 128.204h23.896c2.399 0 4.344-1.945 4.344-4.345v-69.514c0-2.4-1.945-4.345-4.344-4.345h-23.896c-2.399 0-4.345 1.945-4.345 4.345v69.515c.001 2.399 1.946 4.344 4.345 4.344z"/><path d="m162.653 128.204h23.896c2.399 0 4.345-1.945 4.345-4.345v-69.514c0-2.4-1.945-4.345-4.345-4.345h-23.896c-2.4 0-4.345 1.945-4.345 4.345v69.515c0 2.399 1.945 4.344 4.345 4.344z"/><path d="m132.24 92.797h-23.896c-2.399 0-4.345 1.945-4.345 4.345v69.515c0 2.399 1.945 4.345 4.345 4.345h23.896c2.4 0 4.345-1.945 4.345-4.345v-69.515c0-2.4-1.945-4.345-4.345-4.345z"/><path d="m448 141.899c5.522 0 10-4.477 10-10v-121.899c0-5.523-4.478-10-10-10h-315.571c-2.652 0-5.195 1.054-7.071 2.929l-68.429 68.428c-1.875 1.876-2.929 4.419-2.929 7.072v131.688c0 5.523 4.478 10 10 10h6.293v22.909h-6.293c-5.522 0-10 4.477-10 10v248.974c0 5.523 4.478 10 10 10h384c5.522 0 10-4.477 10-10v-256.351c0-5.523-4.478-10-10-10h-6.293v-93.75zm-10-20h-6.293c-5.522 0-10 4.477-10 10v113.75c0 5.523 4.478 10 10 10h6.293v236.351h-364v-228.974h6.293c5.522 0 10-4.477 10-10v-42.909c0-5.523-4.478-10-10-10h-6.293v-117.546l62.571-62.571h301.429z"/></g></svg>

+ 0 - 1
mockup/icons/reload.svg

@@ -1 +0,0 @@
-<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m22 11a1 1 0 0 0 -1 1 9 9 0 1 1 -9-9 8.9 8.9 0 0 1 4.42 1.166l-1.127 1.127a1 1 0 0 0 .707 1.707h4a1 1 0 0 0 1-1v-4a1 1 0 0 0 -1.707-.707l-1.411 1.407a10.9 10.9 0 0 0 -5.882-1.7 11 11 0 1 0 11 11 1 1 0 0 0 -1-1z"/></svg>

File diff suppressed because it is too large
+ 0 - 0
mockup/icons/settings.svg


BIN
mockup/icons/single-extruder1.png


BIN
mockup/icons/single-extruder1.xcf


BIN
mockup/icons/single-extruder2.png


BIN
mockup/icons/single-extruder2.xcf


+ 0 - 1
mockup/icons/skip-objects.svg

@@ -1 +0,0 @@
-<svg id="Layer_1" height="512" viewBox="0 0 32 32" width="512" xmlns="http://www.w3.org/2000/svg" data-name="Layer 1"><path d="m30 17-3 3-3-3h2c0-6.0654-4.9355-11-11-11s-11 4.9346-11 11h-2c0-7.168 5.832-13 13-13s13 5.832 13 13zm0 5h-6v2h6zm-10 0h-2v2h2zm-16 0h-2v2h2zm4 0h-2v2h2zm4 0h-2v2h2zm4 0h-2v2h2z"/></svg>

+ 0 - 53
mockup/icons/snowflake.svg

@@ -1,53 +0,0 @@
-<?xml version="1.0" encoding="iso-8859-1"?>
-<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
-<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
-	 viewBox="0 0 412.8 412.8" style="enable-background:new 0 0 412.8 412.8;" xml:space="preserve">
-<g>
-	<g>
-		<path d="M378.4,225.6L304,251.2L274,234v-27.6v-27.2l30-17.2l74.4,25.6c5.2,2,11.2-1.2,12.8-6.4c2-5.2-1.2-11.2-6.4-12.8
-			l-57.6-19.6l54-31.2c4.8-2.8,6.4-9.2,3.6-14c-2.8-4.8-9.2-6.4-14-3.6l-54,31.2l11.6-59.6c1.2-5.6-2.4-10.8-8-12
-			c-5.6-1.2-10.8,2.4-12,8l-15.2,77.2l-30,17.2l-22.8-13.2l-0.4-0.4l-23.2-13.6v-34.4L276,48.8c4.4-3.6,4.8-10,0.8-14.4
-			c-3.6-4.4-10-4.8-14.4-0.8l-45.6,40V10.4c0-5.6-4.4-10.4-10.4-10.4C200.8,0,196,4.4,196,10.4v62.4l-45.6-39.6
-			C146,29.6,139.6,30,136,34c-3.6,4.4-3.2,10.8,0.8,14.4L196,100v34.4L172.8,148l-23.2,13.6l-30-17.2l-15.2-77.2
-			c-1.2-5.6-6.4-9.2-12-8c-5.6,1.2-9.2,6.4-8,12L96,130.8L42,99.6c-4.8-2.8-11.2-1.2-14,3.6s-1.2,11.2,3.6,14l54,31.2L28,168
-			c-5.2,2-8.4,7.6-6.4,12.8s7.6,8.4,12.8,6.4l74.4-25.6l30,17.2v27.6v27.2h0.4l-30,17.2l-74.4-25.6c-5.2-2-11.2,1.2-12.8,6.4
-			c-2,5.2,1.2,11.2,6.4,12.8L86,264l-54,31.2c-4.8,2.8-6.4,9.2-3.6,14c2.8,4.8,9.2,6.4,14,3.6l54-31.2l-11.6,59.6
-			c-1.2,5.6,2.4,10.8,8,12c5.6,1.2,10.8-2.4,12-8L120,268l30-17.2l23.6,13.6l23.2,13.6v34.4L137.6,364c-4.4,3.6-4.8,10-0.8,14.4
-			c3.6,4.4,10,4.8,14.4,0.8l45.6-40v63.2c0,5.6,4.4,10.4,10.4,10.4c5.6,0,10.4-4.4,10.4-10.4V340l45.6,40c4.4,3.6,10.8,3.2,14.4-0.8
-			c3.6-4.4,3.2-10.8-0.8-14.4l-60-52v-34.4l23.2-13.6l23.2-13.6l30,17.2l15.2,77.2c1.2,5.6,6.4,9.2,12,8c5.6-1.2,9.2-6.4,8-12
-			L316.8,282l54,31.2c4.8,2.8,11.2,1.2,14-3.6c2.8-4.8,1.2-11.2-3.6-14l-54-31.2l57.6-19.6c5.2-2,8.4-7.6,6.4-12.8
-			C389.2,226.8,383.6,223.6,378.4,225.6z M252.4,206.4v27.2l-23.2,13.6l-22.8,13.2l-23.6-13.6l-23.2-13.6v-26.8v-27.2l23.2-13.6
-			L206,152l23.2,13.6l0.4,0.4l22.8,13.2V206.4z"/>
-	</g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-</svg>

File diff suppressed because it is too large
+ 0 - 0
mockup/icons/speed.svg


+ 0 - 1
mockup/icons/temperature.svg

@@ -1 +0,0 @@
-<svg id="Layer_1" height="512" viewBox="0 0 64 64" width="512" xmlns="http://www.w3.org/2000/svg" data-name="Layer 1"><path d="m36 39.47v-32.47a7 7 0 1 0 -14 0v32.47a13.26 13.26 0 1 0 14 0zm-7 22.59a11.32 11.32 0 0 1 -5.53-21.19 1 1 0 0 0 .53-.87v-33a5 5 0 1 1 10 0v33a1 1 0 0 0 .49.84 11.32 11.32 0 0 1 -5.49 21.22z"/><path d="m30 44.89v-30.25a1 1 0 1 0 -1.94 0v30.25a5.94 5.94 0 1 0 1.94 0zm-1 9.85a4 4 0 1 1 4-4 4 4 0 0 1 -4 4z"/><path d="m40.32 9.64h7a1 1 0 0 0 0-1.94h-7a1 1 0 1 0 0 1.94z"/><path d="m40.32 16.06h4.06a1 1 0 0 0 0-1.94h-4.06a1 1 0 0 0 0 1.94z"/><path d="m47.29 20.55h-7a1 1 0 1 0 0 1.93h7a1 1 0 1 0 0-1.93z"/><path d="m40.32 28.91h4.06a1 1 0 0 0 0-1.94h-4.06a1 1 0 0 0 0 1.94z"/><path d="m47.29 33.39h-7a1 1 0 0 0 0 1.94h7a1 1 0 1 0 0-1.94z"/></svg>

File diff suppressed because it is too large
+ 0 - 0
mockup/icons/ventilation.svg


+ 0 - 1
mockup/icons/video-camera.svg

@@ -1 +0,0 @@
-<svg height="472pt" viewBox="0 -87 472 472" width="472pt" xmlns="http://www.w3.org/2000/svg"><path d="m467.101562 26.527344c-3.039062-1.800782-6.796874-1.871094-9.898437-.179688l-108.296875 59.132813v-35.480469c-.03125-27.601562-22.398438-49.96875-50-50h-248.90625c-27.601562.03125-49.96875 22.398438-50 50v197.421875c.03125 27.601563 22.398438 49.96875 50 50h248.90625c27.601562-.03125 49.96875-22.398437 50-50v-34.835937l108.300781 59.132812c3.097657 1.691406 6.859375 1.625 9.894531-.175781 3.039063-1.804688 4.898438-5.074219 4.898438-8.601563v-227.816406c0-3.53125-1.863281-6.796875-4.898438-8.597656zm-138.203124 220.898437c-.015626 16.5625-13.4375 29.980469-30 30h-248.898438c-16.5625-.019531-29.980469-13.4375-30-30v-197.425781c.019531-16.558594 13.4375-29.980469 30-30h248.90625c16.558594.019531 29.980469 13.441406 30 30zm123.101562-1.335937-103.09375-56.289063v-81.535156l103.09375-56.285156zm0 0"/></svg>

+ 0 - 2
mockup/icons/water.svg

@@ -1,2 +0,0 @@
-<?xml version="1.0"?>
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="512" height="512"><g id="Water"><path d="M24,46A16.0183,16.0183,0,0,1,8,30C8,16.0942,22.708,2.8125,23.3345,2.2539a.9983.9983,0,0,1,1.331,0C25.292,2.8125,40,16.0942,40,30A16.0183,16.0183,0,0,1,24,46ZM24,4.3721C21.1333,7.1372,10,18.6118,10,30a14,14,0,0,0,28,0C38,18.6118,26.8667,7.1372,24,4.3721Z"/><path d="M18.4976,40.5273a.9946.9946,0,0,1-.5-.1342A12.0449,12.0449,0,0,1,12,30a1,1,0,0,1,2,0,10.0373,10.0373,0,0,0,5,8.6616,1,1,0,0,1-.5019,1.8657Z"/></g></svg>

Some files were not shown because too many files changed in this diff