Parcourir la source

Merge pull request #279 from maziggy/0.1.8b

v0.1.8b
MartinNYHC il y a 3 mois
Parent
commit
996f78870f
100 fichiers modifiés avec 6163 ajouts et 972 suppressions
  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'
           format: 'sarif'
           output: 'trivy-results.sarif'
           output: 'trivy-results.sarif'
           severity: 'CRITICAL,HIGH,MEDIUM'
           severity: 'CRITICAL,HIGH,MEDIUM'
+          version: 'v0.69.1'
 
 
       - name: Upload Trivy results to GitHub Security
       - name: Upload Trivy results to GitHub Security
         uses: github/codeql-action/upload-sarif@v4
         uses: github/codeql-action/upload-sarif@v4
@@ -95,6 +96,7 @@ jobs:
           format: 'sarif'
           format: 'sarif'
           output: 'trivy-config-results.sarif'
           output: 'trivy-config-results.sarif'
           severity: 'CRITICAL,HIGH,MEDIUM'
           severity: 'CRITICAL,HIGH,MEDIUM'
+          version: 'v0.69.1'
 
 
       - name: Upload Trivy config results
       - name: Upload Trivy config results
         uses: github/codeql-action/upload-sarif@v4
         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.
 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
 ### Security
 - **Critical: Missing API Endpoint Authentication** (CVE-2026-25505, CVSS 9.8):
 - **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
 COPY --from=frontend-builder /app/static ./static
 
 
 # Create data directory for persistent storage
 # 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
 # Environment variables
 ENV PYTHONUNBUFFERED=1
 ENV PYTHONUNBUFFERED=1

+ 2 - 1
Dockerfile.test

@@ -26,7 +26,8 @@ ENV DATA_DIR=/app/data
 ENV TESTING=1
 ENV TESTING=1
 
 
 # Default command runs pytest (excluding docker integration tests)
 # 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
 # Frontend test stage

+ 21 - 2
README.md

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

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

@@ -466,10 +466,8 @@ async def get_archive_stats(
             total_seconds += print_time_seconds
             total_seconds += print_time_seconds
     total_time = total_seconds / 3600  # Convert to hours
     total_time = total_seconds / 3600  # Convert to hours
 
 
-    # Multiply filament by quantity to account for multiple items printed
-    filament_result = await db.execute(
-        select(func.sum(PrintArchive.filament_used_grams * func.coalesce(PrintArchive.quantity, 1)))
-    )
+    # Sum filament directly - filament_used_grams already contains the total for the print job
+    filament_result = await db.execute(select(func.coalesce(func.sum(PrintArchive.filament_used_grams), 0)))
     total_filament = filament_result.scalar() or 0
     total_filament = filament_result.scalar() or 0
 
 
     cost_result = await db.execute(select(func.sum(PrintArchive.cost)))
     cost_result = await db.execute(select(func.sum(PrintArchive.cost)))
@@ -1592,7 +1590,10 @@ async def process_timelapse(
             raise HTTPException(400, "Audio must be .mp3, .wav, .m4a, .aac, or .ogg")
             raise HTTPException(400, "Audio must be .mp3, .wav, .m4a, .aac, or .ogg")
 
 
         audio_content = await audio.read()
         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 = Path(tempfile.gettempdir()) / f"audio_{archive_id}{suffix}"
         audio_temp_path.write_bytes(audio_content)
         audio_temp_path.write_bytes(audio_content)
 
 
@@ -1607,8 +1608,11 @@ async def process_timelapse(
         else:
         else:
             # Save as new file alongside original
             # Save as new file alongside original
             filename = output_filename or f"{archive.print_name or 'timelapse'}_edited.mp4"
             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 "._- ")
             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"):
             if not filename.endswith(".mp4"):
                 filename += ".mp4"
                 filename += ".mp4"
             output_path = archive_dir / filename
             output_path = archive_dir / filename
@@ -1835,7 +1839,8 @@ async def get_archive_capabilities(
 ):
 ):
     """Check what viewing capabilities are available for this 3MF file."""
     """Check what viewing capabilities are available for this 3MF file."""
     import json
     import json
-    import xml.etree.ElementTree as ET
+
+    import defusedxml.ElementTree as ET
 
 
     service = ArchiveService(db)
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
     archive = await service.get_archive(archive_id)
@@ -2115,7 +2120,7 @@ async def get_plate_preview(
             plate_num = 1
             plate_num = 1
             if "Metadata/slice_info.config" in names:
             if "Metadata/slice_info.config" in names:
                 try:
                 try:
-                    import xml.etree.ElementTree as ET
+                    import defusedxml.ElementTree as ET
 
 
                     slice_content = zf.read("Metadata/slice_info.config").decode("utf-8")
                     slice_content = zf.read("Metadata/slice_info.config").decode("utf-8")
                     root = ET.fromstring(slice_content)
                     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,
     Returns a list of plates with their index, name, thumbnail availability,
     and filament requirements. For single-plate exports, returns a single plate.
     and filament requirements. For single-plate exports, returns a single plate.
     """
     """
-    import xml.etree.ElementTree as ET
+    import json
+    import re
+
+    import defusedxml.ElementTree as ET
 
 
     service = ArchiveService(db)
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
     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
             # Find all plate gcode files to determine available plates
             gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
             gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
 
 
-            if not gcode_files:
-                # No sliced plates found
-                return {"archive_id": archive_id, "filename": archive.filename, "plates": []}
-
-            # Extract plate indices from gcode filenames
-            plate_indices = []
-            for gf in gcode_files:
-                # "Metadata/plate_5.gcode" -> 5
-                try:
-                    plate_str = gf[15:-6]  # Remove "Metadata/plate_" and ".gcode"
-                    plate_indices.append(int(plate_str))
-                except ValueError:
-                    pass
+            # If no gcode is present (source-only or unsliced), fall back to plate JSON/PNG
+            plate_indices: list[int] = []
+            if gcode_files:
+                # Extract plate indices from gcode filenames
+                for gf in gcode_files:
+                    # "Metadata/plate_5.gcode" -> 5
+                    try:
+                        plate_str = gf[15:-6]  # Remove "Metadata/plate_" and ".gcode"
+                        plate_indices.append(int(plate_str))
+                    except ValueError:
+                        pass
+            else:
+                plate_json_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".json")]
+                plate_png_files = [
+                    n
+                    for n in namelist
+                    if n.startswith("Metadata/plate_")
+                    and n.endswith(".png")
+                    and "_small" not in n
+                    and "no_light" not in n
+                ]
+                plate_name_candidates = plate_json_files + plate_png_files
+                plate_re = re.compile(r"^Metadata/plate_(\d+)\.(json|png)$")
+                seen_indices: set[int] = set()
+                for name in plate_name_candidates:
+                    match = plate_re.match(name)
+                    if match:
+                        try:
+                            index = int(match.group(1))
+                        except ValueError:
+                            continue
+                        if index in seen_indices:
+                            continue
+                        seen_indices.add(index)
+                        plate_indices.append(index)
+
+            if not plate_indices:
+                # No plate metadata found
+                return {
+                    "archive_id": archive_id,
+                    "filename": archive.filename,
+                    "plates": [],
+                    "is_multi_plate": False,
+                }
 
 
             plate_indices.sort()
             plate_indices.sort()
 
 
-            # 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 are stored with plater_id and plater_name keys
             plate_names = {}  # plater_id -> name
             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:
             if "Metadata/model_settings.config" in namelist:
                 try:
                 try:
                     model_content = zf.read("Metadata/model_settings.config").decode()
                     model_content = zf.read("Metadata/model_settings.config").decode()
                     model_root = ET.fromstring(model_content)
                     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"):
                     for plate_elem in model_root.findall(".//plate"):
                         plater_id = None
                         plater_id = None
                         plater_name = None
                         plater_name = None
@@ -2312,6 +2363,17 @@ async def get_archive_plates(
                                 plater_name = value.strip()
                                 plater_name = value.strip()
                         if plater_id is not None and plater_name:
                         if plater_id is not None and plater_name:
                             plate_names[plater_id] = 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:
                 except Exception:
                     pass  # model_settings.config parsing is optional
                     pass  # model_settings.config parsing is optional
 
 
@@ -2391,16 +2453,53 @@ async def get_archive_plates(
 
 
                         plate_metadata[plate_index] = plate_info
                         plate_metadata[plate_index] = plate_info
 
 
+            # Parse plate_*.json for object lists when slice_info is missing
+            plate_json_objects: dict[int, list[str]] = {}
+            for name in namelist:
+                match = re.match(r"^Metadata/plate_(\d+)\.json$", name)
+                if not match:
+                    continue
+                try:
+                    plate_index = int(match.group(1))
+                except ValueError:
+                    continue
+                try:
+                    payload = json.loads(zf.read(name).decode())
+                    bbox_objects = payload.get("bbox_objects", [])
+                    names = []
+                    for obj in bbox_objects:
+                        obj_name = obj.get("name") if isinstance(obj, dict) else None
+                        if obj_name and obj_name not in names:
+                            names.append(obj_name)
+                    if names:
+                        plate_json_objects[plate_index] = names
+                except Exception:
+                    continue
+
             # Build plate list
             # Build plate list
             for idx in plate_indices:
             for idx in plate_indices:
                 meta = plate_metadata.get(idx, {})
                 meta = plate_metadata.get(idx, {})
                 has_thumbnail = f"Metadata/plate_{idx}.png" in namelist
                 has_thumbnail = f"Metadata/plate_{idx}.png" in namelist
+                objects = meta.get("objects", [])
+                if not objects:
+                    objects = plate_json_objects.get(idx, [])
+                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(
                 plates.append(
                     {
                     {
                         "index": idx,
                         "index": idx,
-                        "name": meta.get("name"),
-                        "objects": meta.get("objects", []),
+                        "name": plate_name,
+                        "objects": objects,
+                        "object_count": len(objects),
                         "has_thumbnail": has_thumbnail,
                         "has_thumbnail": has_thumbnail,
                         "thumbnail_url": f"/api/v1/archives/{archive_id}/plate-thumbnail/{idx}"
                         "thumbnail_url": f"/api/v1/archives/{archive_id}/plate-thumbnail/{idx}"
                         if has_thumbnail
                         if has_thumbnail
@@ -2469,7 +2568,7 @@ async def get_filament_requirements(
         archive_id: The archive ID
         archive_id: The archive ID
         plate_id: Optional plate index to filter filaments for (for multi-plate files)
         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)
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
     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_g = filament_elem.get("used_g", "0")
                                 used_m = filament_elem.get("used_m", "0")
                                 used_m = filament_elem.get("used_m", "0")
 
 
+                                tray_info_idx = filament_elem.get("tray_info_idx", "")
+
                                 try:
                                 try:
                                     used_grams = float(used_g)
                                     used_grams = float(used_g)
                                 except (ValueError, TypeError):
                                 except (ValueError, TypeError):
@@ -2524,6 +2625,7 @@ async def get_filament_requirements(
                                             "color": filament_color,
                                             "color": filament_color,
                                             "used_grams": round(used_grams, 1),
                                             "used_grams": round(used_grams, 1),
                                             "used_meters": float(used_m) if used_m else 0,
                                             "used_meters": float(used_m) if used_m else 0,
+                                            "tray_info_idx": tray_info_idx,
                                         }
                                         }
                                     )
                                     )
                             break
                             break
@@ -2537,6 +2639,8 @@ async def get_filament_requirements(
                         used_g = filament_elem.get("used_g", "0")
                         used_g = filament_elem.get("used_g", "0")
                         used_m = filament_elem.get("used_m", "0")
                         used_m = filament_elem.get("used_m", "0")
 
 
+                        tray_info_idx = filament_elem.get("tray_info_idx", "")
+
                         # Only include filaments that are actually used
                         # Only include filaments that are actually used
                         try:
                         try:
                             used_grams = float(used_g)
                             used_grams = float(used_g)
@@ -2551,6 +2655,7 @@ async def get_filament_requirements(
                                     "color": filament_color,
                                     "color": filament_color,
                                     "used_grams": round(used_grams, 1),
                                     "used_grams": round(used_grams, 1),
                                     "used_meters": float(used_m) if used_m else 0,
                                     "used_meters": float(used_m) if used_m else 0,
+                                    "tray_info_idx": tray_info_idx,
                                 }
                                 }
                             )
                             )
 
 

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

@@ -664,10 +664,12 @@ async def list_files(
         print_name = None
         print_name = None
         print_time = None
         print_time = None
         filament_grams = None
         filament_grams = None
+        sliced_for_model = None
         if f.file_metadata:
         if f.file_metadata:
             print_name = f.file_metadata.get("print_name")
             print_name = f.file_metadata.get("print_name")
             print_time = f.file_metadata.get("print_time_seconds")
             print_time = f.file_metadata.get("print_time_seconds")
             filament_grams = f.file_metadata.get("filament_used_grams")
             filament_grams = f.file_metadata.get("filament_used_grams")
+            sliced_for_model = f.file_metadata.get("sliced_for_model")
 
 
         file_list.append(
         file_list.append(
             FileListResponse(
             FileListResponse(
@@ -685,6 +687,7 @@ async def list_files(
                 print_name=print_name,
                 print_name=print_name,
                 print_time_seconds=print_time,
                 print_time_seconds=print_time,
                 filament_used_grams=filament_grams,
                 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,
     Returns a list of plates with their index, name, thumbnail availability,
     and filament requirements. For single-plate exports, returns a single plate.
     and filament requirements. For single-plate exports, returns a single plate.
     """
     """
-    import xml.etree.ElementTree as ET
+    import json
+    import re
     import zipfile
     import zipfile
 
 
+    import defusedxml.ElementTree as ET
+
     # Get the library file
     # Get the library file
     result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
     result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
     lib_file = result.scalar_one_or_none()
     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
             # Find all plate gcode files to determine available plates
             gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
             gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
 
 
-            if not gcode_files:
-                # No sliced plates found
+            # If no gcode is present (source-only or unsliced), fall back to plate JSON/PNG
+            plate_indices: list[int] = []
+            if gcode_files:
+                # Extract plate indices from gcode filenames
+                for gf in gcode_files:
+                    try:
+                        plate_str = gf[15:-6]  # Remove "Metadata/plate_" and ".gcode"
+                        plate_indices.append(int(plate_str))
+                    except ValueError:
+                        pass
+            else:
+                plate_json_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".json")]
+                plate_png_files = [
+                    n
+                    for n in namelist
+                    if n.startswith("Metadata/plate_")
+                    and n.endswith(".png")
+                    and "_small" not in n
+                    and "no_light" not in n
+                ]
+                plate_name_candidates = plate_json_files + plate_png_files
+                plate_re = re.compile(r"^Metadata/plate_(\d+)\.(json|png)$")
+                seen_indices: set[int] = set()
+                for name in plate_name_candidates:
+                    match = plate_re.match(name)
+                    if match:
+                        try:
+                            index = int(match.group(1))
+                        except ValueError:
+                            continue
+                        if index in seen_indices:
+                            continue
+                        seen_indices.add(index)
+                        plate_indices.append(index)
+
+            if not plate_indices:
+                # No plate metadata found
                 return {"file_id": file_id, "filename": lib_file.filename, "plates": [], "is_multi_plate": False}
                 return {"file_id": file_id, "filename": lib_file.filename, "plates": [], "is_multi_plate": False}
 
 
-            # Extract plate indices from gcode filenames
-            plate_indices = []
-            for gf in gcode_files:
-                try:
-                    plate_str = gf[15:-6]  # Remove "Metadata/plate_" and ".gcode"
-                    plate_indices.append(int(plate_str))
-                except ValueError:
-                    pass
-
             plate_indices.sort()
             plate_indices.sort()
 
 
-            # Parse model_settings.config for plate names
+            # Parse model_settings.config for plate names + object assignments
             plate_names = {}
             plate_names = {}
+            plate_object_ids: dict[int, list[str]] = {}
+            object_names_by_id: dict[str, str] = {}
             if "Metadata/model_settings.config" in namelist:
             if "Metadata/model_settings.config" in namelist:
                 try:
                 try:
                     model_content = zf.read("Metadata/model_settings.config").decode()
                     model_content = zf.read("Metadata/model_settings.config").decode()
                     model_root = ET.fromstring(model_content)
                     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"):
                     for plate_elem in model_root.findall(".//plate"):
                         plater_id = None
                         plater_id = None
                         plater_name = None
                         plater_name = None
@@ -1370,6 +1413,17 @@ async def get_library_file_plates(
                                 plater_name = value.strip()
                                 plater_name = value.strip()
                         if plater_id is not None and plater_name:
                         if plater_id is not None and plater_name:
                             plate_names[plater_id] = 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:
                 except Exception:
                     pass
                     pass
 
 
@@ -1443,16 +1497,53 @@ async def get_library_file_plates(
                             plate_info["name"] = plate_info["objects"][0]
                             plate_info["name"] = plate_info["objects"][0]
                         plate_metadata[plate_index] = plate_info
                         plate_metadata[plate_index] = plate_info
 
 
+            # Parse plate_*.json for object lists when slice_info is missing
+            plate_json_objects: dict[int, list[str]] = {}
+            for name in namelist:
+                match = re.match(r"^Metadata/plate_(\d+)\.json$", name)
+                if not match:
+                    continue
+                try:
+                    plate_index = int(match.group(1))
+                except ValueError:
+                    continue
+                try:
+                    payload = json.loads(zf.read(name).decode())
+                    bbox_objects = payload.get("bbox_objects", [])
+                    names: list[str] = []
+                    for obj in bbox_objects:
+                        obj_name = obj.get("name") if isinstance(obj, dict) else None
+                        if obj_name and obj_name not in names:
+                            names.append(obj_name)
+                    if names:
+                        plate_json_objects[plate_index] = names
+                except Exception:
+                    continue
+
             # Build plate list
             # Build plate list
             for idx in plate_indices:
             for idx in plate_indices:
                 meta = plate_metadata.get(idx, {})
                 meta = plate_metadata.get(idx, {})
                 has_thumbnail = f"Metadata/plate_{idx}.png" in namelist
                 has_thumbnail = f"Metadata/plate_{idx}.png" in namelist
+                objects = meta.get("objects", [])
+                if not objects:
+                    objects = plate_json_objects.get(idx, [])
+                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(
                 plates.append(
                     {
                     {
                         "index": idx,
                         "index": idx,
-                        "name": meta.get("name"),
-                        "objects": meta.get("objects", []),
+                        "name": plate_name,
+                        "objects": objects,
+                        "object_count": len(objects),
                         "has_thumbnail": has_thumbnail,
                         "has_thumbnail": has_thumbnail,
                         "thumbnail_url": f"/api/v1/library/files/{file_id}/plate-thumbnail/{idx}"
                         "thumbnail_url": f"/api/v1/library/files/{file_id}/plate-thumbnail/{idx}"
                         if has_thumbnail
                         if has_thumbnail
@@ -1523,9 +1614,10 @@ async def get_library_file_filament_requirements(
         file_id: The library file ID
         file_id: The library file ID
         plate_id: Optional plate index to get filaments for a specific plate
         plate_id: Optional plate index to get filaments for a specific plate
     """
     """
-    import xml.etree.ElementTree as ET
     import zipfile
     import zipfile
 
 
+    import defusedxml.ElementTree as ET
+
     # Get the library file
     # Get the library file
     result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
     result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
     lib_file = result.scalar_one_or_none()
     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_g = filament_elem.get("used_g", "0")
                                 used_m = filament_elem.get("used_m", "0")
                                 used_m = filament_elem.get("used_m", "0")
 
 
+                                tray_info_idx = filament_elem.get("tray_info_idx", "")
+
                                 try:
                                 try:
                                     used_grams = float(used_g)
                                     used_grams = float(used_g)
                                 except (ValueError, TypeError):
                                 except (ValueError, TypeError):
@@ -1587,6 +1681,7 @@ async def get_library_file_filament_requirements(
                                             "color": filament_color,
                                             "color": filament_color,
                                             "used_grams": round(used_grams, 1),
                                             "used_grams": round(used_grams, 1),
                                             "used_meters": float(used_m) if used_m else 0,
                                             "used_meters": float(used_m) if used_m else 0,
+                                            "tray_info_idx": tray_info_idx,
                                         }
                                         }
                                     )
                                     )
                             break
                             break
@@ -1599,6 +1694,8 @@ async def get_library_file_filament_requirements(
                         used_g = filament_elem.get("used_g", "0")
                         used_g = filament_elem.get("used_g", "0")
                         used_m = filament_elem.get("used_m", "0")
                         used_m = filament_elem.get("used_m", "0")
 
 
+                        tray_info_idx = filament_elem.get("tray_info_idx", "")
+
                         try:
                         try:
                             used_grams = float(used_g)
                             used_grams = float(used_g)
                         except (ValueError, TypeError):
                         except (ValueError, TypeError):
@@ -1612,6 +1709,7 @@ async def get_library_file_filament_requirements(
                                     "color": filament_color,
                                     "color": filament_color,
                                     "used_grams": round(used_grams, 1),
                                     "used_grams": round(used_grams, 1),
                                     "used_meters": float(used_m) if used_m else 0,
                                     "used_meters": float(used_m) if used_m else 0,
+                                    "tray_info_idx": tray_info_idx,
                                 }
                                 }
                             )
                             )
 
 
@@ -1870,6 +1968,17 @@ async def get_file(
             )
             )
         duplicate_count = len(duplicates)
         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(
     return FileResponseSchema(
         id=file.id,
         id=file.id,
         folder_id=file.folder_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_by_username=file.created_by.username if file.created_by else None,
         created_at=file.created_at,
         created_at=file.created_at,
         updated_at=file.updated_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}")
             lines.append(f"bambuddy_printer_prints_total{labels} {count}")
 
 
-    # Total filament used (multiply by quantity to account for multiple items printed)
+    # Total filament used - filament_used_grams already contains the total for each print job
     lines.append("")
     lines.append("")
     lines.append("# HELP bambuddy_filament_used_grams Total filament used in grams")
     lines.append("# HELP bambuddy_filament_used_grams Total filament used in grams")
     lines.append("# TYPE bambuddy_filament_used_grams counter")
     lines.append("# TYPE bambuddy_filament_used_grams counter")
-    result = await db.execute(
-        select(func.coalesce(func.sum(PrintArchive.filament_used_grams * func.coalesce(PrintArchive.quantity, 1)), 0))
-    )
+    result = await db.execute(select(func.coalesce(func.sum(PrintArchive.filament_used_grams), 0)))
     total_filament = result.scalar() or 0
     total_filament = result.scalar() or 0
     lines.append(f"bambuddy_filament_used_grams {total_filament:.1f}")
     lines.append(f"bambuddy_filament_used_grams {total_filament:.1f}")
 
 

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

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

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

@@ -2,11 +2,11 @@
 
 
 import json
 import json
 import logging
 import logging
-import xml.etree.ElementTree as ET
 import zipfile
 import zipfile
 from datetime import datetime
 from datetime import datetime
 from pathlib import Path
 from pathlib import Path
 
 
+import defusedxml.ElementTree as ET
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi import APIRouter, Depends, HTTPException, Query
 from sqlalchemy import func, select
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 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)
     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:
 def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
     """Add nested archive/printer/library_file info to response."""
     """Add nested archive/printer/library_file info to response."""
     # Parse ams_mapping from JSON string BEFORE model_validate
     # 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_name = item.archive.print_name or item.archive.filename
         response.archive_thumbnail = item.archive.thumbnail_path
         response.archive_thumbnail = item.archive.thumbnail_path
         response.print_time_seconds = item.archive.print_time_seconds
         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:
     if item.library_file:
         response.library_file_name = (
         response.library_file_name = (
             item.library_file.file_metadata.get("print_name") if item.library_file.file_metadata else None
             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
         # Get print time from library file metadata if no archive
         if not item.archive and item.library_file.file_metadata:
         if not item.archive and item.library_file.file_metadata:
             response.print_time_seconds = item.library_file.file_metadata.get("print_time_seconds")
             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:
     if item.printer:
         response.printer_name = item.printer.name
         response.printer_name = item.printer.name
     return response
     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(
 async def get_printer_cover(
     printer_id: int,
     printer_id: int,
     view: str | None = None,
     view: str | None = None,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
+    # Note: No auth required - this is an image asset loaded via <img src> which can't send auth headers
     """Get the cover image for the current print job.
     """Get the cover image for the current print job.
 
 
     Args:
     Args:
@@ -803,6 +803,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")
 @router.post("/{printer_id}/files/download-zip")
 async def download_printer_files_as_zip(
 async def download_printer_files_as_zip(
     printer_id: int,
     printer_id: int,

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

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

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

@@ -158,6 +158,29 @@ async def is_auth_enabled(db: AsyncSession) -> bool:
         return False
         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(
 async def get_current_user_optional(
     credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
     credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
 ) -> User | 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(
 async def require_auth_if_enabled(
     credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
     credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+    x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
 ) -> User | 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:
     async with async_session() as db:
         auth_enabled = await is_auth_enabled(db)
         auth_enabled = await is_auth_enabled(db)
         if not auth_enabled:
         if not auth_enabled:
             return None
             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
             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(
                 raise HTTPException(
                     status_code=status.HTTP_401_UNAUTHORIZED,
                     status_code=status.HTTP_401_UNAUTHORIZED,
                     detail="Could not validate credentials",
                     detail="Could not validate credentials",
                     headers={"WWW-Authenticate": "Bearer"},
                     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):
 def require_role(required_role: str):
@@ -420,6 +468,9 @@ def RequireAdminIfAuthEnabled():
 def require_permission(*permissions: str | Permission):
 def require_permission(*permissions: str | Permission):
     """Dependency factory that requires user to have ALL specified permissions.
     """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:
     Args:
         *permissions: Permission strings or Permission enum values to require
         *permissions: Permission strings or Permission enum values to require
 
 
@@ -431,25 +482,45 @@ def require_permission(*permissions: str | Permission):
 
 
     async def permission_checker(
     async def permission_checker(
         credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
         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
             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
                 raise credentials_exception
-        except JWTError:
-            raise credentials_exception
 
 
-        async with async_session() as db:
             user = await get_user_by_username(db, username)
             user = await get_user_by_username(db, username)
             if user is None or not user.is_active:
             if user is None or not user.is_active:
                 raise credentials_exception
                 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.
     """Dependency factory that checks permissions only if auth is enabled.
 
 
     This provides backward compatibility - when auth is disabled, all access is allowed.
     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:
     Args:
         *permissions: Permission strings or Permission enum values to require
         *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(
     async def permission_checker(
         credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
         credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+        x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
     ) -> User | None:
     ) -> User | None:
         async with async_session() as db:
         async with async_session() as db:
             auth_enabled = await is_auth_enabled(db)
             auth_enabled = await is_auth_enabled(db)
             if not auth_enabled:
             if not auth_enabled:
                 return None  # Auth disabled, allow access
                 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
                 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(
                     raise HTTPException(
                         status_code=status.HTTP_401_UNAUTHORIZED,
                         status_code=status.HTTP_401_UNAUTHORIZED,
                         detail="Could not validate credentials",
                         detail="Could not validate credentials",
                         headers={"WWW-Authenticate": "Bearer"},
                         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
     return permission_checker
 
 
@@ -547,6 +641,7 @@ def require_ownership_permission(
     - User with `all_permission` can modify any item
     - User with `all_permission` can modify any item
     - User with `own_permission` can only modify items where created_by_id == user.id
     - User with `own_permission` can only modify items where created_by_id == user.id
     - Ownerless items (created_by_id = null) require `all_permission`
     - 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:
     Returns:
         A dependency function that returns (user, can_modify_all).
         A dependency function that returns (user, can_modify_all).
@@ -558,6 +653,7 @@ def require_ownership_permission(
 
 
     async def checker(
     async def checker(
         credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
         credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+        x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
     ) -> tuple[User | None, bool]:
     ) -> tuple[User | None, bool]:
         """Returns (user, can_modify_all).
         """Returns (user, can_modify_all).
 
 
@@ -569,46 +665,66 @@ def require_ownership_permission(
             if not auth_enabled:
             if not auth_enabled:
                 return None, True  # Auth disabled, allow all
                 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
                 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(
                     raise HTTPException(
                         status_code=status.HTTP_401_UNAUTHORIZED,
                         status_code=status.HTTP_401_UNAUTHORIZED,
                         detail="Could not validate credentials",
                         detail="Could not validate credentials",
                         headers={"WWW-Authenticate": "Bearer"},
                         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(
                 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(
             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
     return checker

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

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

+ 122 - 11
backend/app/main.py

@@ -1,7 +1,7 @@
 import asyncio
 import asyncio
 import logging
 import logging
 from contextlib import asynccontextmanager
 from contextlib import asynccontextmanager
-from datetime import UTC, datetime, timedelta
+from datetime import datetime, timedelta, timezone
 from logging.handlers import RotatingFileHandler
 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 is in minutes, convert to seconds for notification
                     remaining_time_seconds = state.remaining_time * 60 if state.remaining_time else None
                     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(
                     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:
             except Exception as e:
                 logging.getLogger(__name__).warning(f"Progress milestone notification failed: {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:
         if new_error_codes:
             # Get the actual new errors for the notification
             # 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:
             try:
                 async with async_session() as db:
                 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
                     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:
                     for error in new_errors:
                         module_name = module_names.get(error.module, f"Module 0x{error.module:02X}")
                         module_name = module_names.get(error.module, f"Module 0x{error.module:02X}")
                         # Build short code like "0700_8010"
                         # 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
                         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"
                         error_type = f"{module_name} Error"
                         # Look up human-readable description
                         # 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}"
                         error_detail = description if description else f"Error code: {short_code}"
 
 
                         await notification_service.on_printer_error(
                         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(
                     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}")
         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(
 async def _send_print_start_notification(
     printer_id: int,
     printer_id: int,
     data: dict,
     data: dict,
@@ -658,6 +734,14 @@ async def _send_print_start_notification(
             result = await db.execute(select(Printer).where(Printer.id == printer_id))
             result = await db.execute(select(Printer).where(Printer.id == printer_id))
             printer = result.scalar_one_or_none()
             printer = result.scalar_one_or_none()
             printer_name = printer.name if printer else f"Printer {printer_id}"
             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)
             await notification_service.on_print_start(printer_id, printer_name, data, db, archive_data=archive_data)
     except Exception as e:
     except Exception as e:
         logger.warning(f"Notification on_print_start failed: {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()
         printer = result.scalar_one_or_none()
 
 
         # Plate detection check - pause if objects detected on build plate
         # Plate detection check - pause if objects detected on build plate
+        logger.info(
+            f"[PLATE CHECK] printer_id={printer_id}, plate_detection_enabled={printer.plate_detection_enabled if printer else 'NO PRINTER'}"
+        )
         if printer and printer.plate_detection_enabled:
         if printer and printer.plate_detection_enabled:
+            logger.info(f"[PLATE CHECK] ENTERING plate detection code for printer {printer_id}")
             try:
             try:
                 from backend.app.services.plate_detection import check_plate_empty
                 from backend.app.services.plate_detection import check_plate_empty
 
 
@@ -939,7 +1027,7 @@ async def on_print_start(printer_id: int, data: dict):
         if existing_archive:
         if existing_archive:
             # Check if archive is stale (older than 4 hours) - likely a failed/cancelled print
             # Check if archive is stale (older than 4 hours) - likely a failed/cancelled print
             # that didn't get properly updated
             # 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
             if archive_age.total_seconds() > 4 * 60 * 60:  # 4 hours
                 logger.warning(
                 logger.warning(
                     f"Found stale 'printing' archive {existing_archive.id} (age: {archive_age}), "
                     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,
                             "actual_filament_grams": archive.filament_used_grams,
                             "failure_reason": archive.failure_reason,
                             "failure_reason": archive.failure_reason,
                         }
                         }
-                        # Add finish photo URL if available
+                        # Add finish photo URL and image bytes if available
                         if finish_photo_filename:
                         if finish_photo_filename:
                             from backend.app.api.routes.settings import get_setting
                             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}"
                                     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(
                 await notification_service.on_print_complete(
                     printer_id, printer_name, print_status, data, db, archive_data=archive_data
                     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
         # Restore MQTT smart plug subscriptions
         if mqtt_settings.get("mqtt_enabled"):
         if mqtt_settings.get("mqtt_enabled"):
-            from sqlalchemy import select
-
             from backend.app.models.smart_plug import SmartPlug
             from backend.app.models.smart_plug import SmartPlug
 
 
             result = await db.execute(select(SmartPlug).where(SmartPlug.plug_type == "mqtt"))
             result = await db.execute(select(SmartPlug).where(SmartPlug.plug_type == "mqtt"))
@@ -2501,6 +2610,7 @@ async def lifespan(app: FastAPI):
             vp_mode = await get_setting(db, "virtual_printer_mode") or "immediate"
             vp_mode = await get_setting(db, "virtual_printer_mode") or "immediate"
             vp_model = await get_setting(db, "virtual_printer_model") or ""
             vp_model = await get_setting(db, "virtual_printer_model") or ""
             vp_target_printer_id = await get_setting(db, "virtual_printer_target_printer_id")
             vp_target_printer_id = await get_setting(db, "virtual_printer_target_printer_id")
+            vp_remote_iface = await get_setting(db, "virtual_printer_remote_interface_ip") or ""
 
 
             # Look up printer IP and serial if in proxy mode
             # Look up printer IP and serial if in proxy mode
             vp_target_ip = ""
             vp_target_ip = ""
@@ -2526,6 +2636,7 @@ async def lifespan(app: FastAPI):
                         model=vp_model,
                         model=vp_model,
                         target_printer_ip=vp_target_ip,
                         target_printer_ip=vp_target_ip,
                         target_printer_serial=vp_target_serial,
                         target_printer_serial=vp_target_serial,
+                        remote_interface_ip=vp_remote_iface,
                     )
                     )
                     if vp_mode == "proxy":
                     if vp_mode == "proxy":
                         logging.info(f"Virtual printer proxy started (target={vp_target_ip})")
                         logging.info(f"Virtual printer proxy started (target={vp_target_ip})")
@@ -2662,9 +2773,9 @@ async def auth_middleware(request, call_next):
         )
         )
 
 
     # Validate JWT token
     # Validate JWT token
-    try:
-        import jwt
+    import jwt
 
 
+    try:
         from backend.app.core.auth import ALGORITHM, SECRET_KEY
         from backend.app.core.auth import ALGORITHM, SECRET_KEY
 
 
         token = auth_header.replace("Bearer ", "")
         token = auth_header.replace("Bearer ", "")

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

@@ -130,6 +130,12 @@ class FileResponse(BaseModel):
     created_at: datetime
     created_at: datetime
     updated_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:
     class Config:
         from_attributes = True
         from_attributes = True
 
 
@@ -154,6 +160,7 @@ class FileListResponse(BaseModel):
     print_name: str | None = None
     print_name: str | None = None
     print_time_seconds: int | None = None
     print_time_seconds: int | None = None
     filament_used_grams: float | None = None
     filament_used_grams: float | None = None
+    sliced_for_model: str | None = None
 
 
     class Config:
     class Config:
         from_attributes = True
         from_attributes = True

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

@@ -6,8 +6,8 @@ import shutil
 import zipfile
 import zipfile
 from datetime import datetime
 from datetime import datetime
 from pathlib import Path
 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 import and_, or_, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 

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

@@ -77,9 +77,16 @@ class BambuFTPClient:
 
 
     FTP_PORT = 990
     FTP_PORT = 990
     DEFAULT_TIMEOUT = 30  # Default timeout in seconds (increased for A1 printers)
     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__(
     def __init__(
         self,
         self,
@@ -87,38 +94,70 @@ class BambuFTPClient:
         access_code: str,
         access_code: str,
         timeout: float | None = None,
         timeout: float | None = None,
         printer_model: str | None = None,
         printer_model: str | None = None,
+        force_prot_c: bool = False,
     ):
     ):
         self.ip_address = ip_address
         self.ip_address = ip_address
         self.access_code = access_code
         self.access_code = access_code
         self.timeout = timeout if timeout is not None else self.DEFAULT_TIMEOUT
         self.timeout = timeout if timeout is not None else self.DEFAULT_TIMEOUT
         self.printer_model = printer_model
         self.printer_model = printer_model
+        self.force_prot_c = force_prot_c
         self._ftp: ImplicitFTP_TLS | None = None
         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:
         if not self.printer_model:
             return False
             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:
     def connect(self) -> bool:
         """Connect to the printer FTP server (implicit FTPS on port 990)."""
         """Connect to the printer FTP server (implicit FTPS on port 990)."""
         try:
         try:
-            skip_reuse = self._should_skip_session_reuse()
+            use_prot_c = self._should_use_prot_c()
             logger.debug(
             logger.debug(
                 f"FTP connecting to {self.ip_address}:{self.FTP_PORT} "
                 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)
             self._ftp.connect(self.ip_address, self.FTP_PORT, timeout=self.timeout)
             logger.debug("FTP connected, logging in as bblp")
             logger.debug("FTP connected, logging in as bblp")
             self._ftp.login("bblp", self.access_code)
             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)
             self._ftp.set_pasv(True)
             # Log welcome message for debugging
             # Log welcome message for debugging
             if hasattr(self._ftp, "welcome") and self._ftp.welcome:
             if hasattr(self._ftp, "welcome") and self._ftp.welcome:
                 logger.debug(f"FTP server welcome: {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
             return True
         except ftplib.error_perm as e:
         except ftplib.error_perm as e:
             logger.warning(f"FTP connection permission error to {self.ip_address}: {e}")
             logger.warning(f"FTP connection permission error to {self.ip_address}: {e}")
@@ -327,18 +366,37 @@ class BambuFTPClient:
 
 
             uploaded = 0
             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:
             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}")
                 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}")
             logger.info(f"FTP upload complete: {remote_path}")
             return True
             return True
         except ftplib.error_perm as e:
         except ftplib.error_perm as e:
@@ -366,8 +424,24 @@ class BambuFTPClient:
             return False
             return False
 
 
         try:
         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
             return True
         except Exception:
         except Exception:
             return False
             return False
@@ -458,6 +532,9 @@ async def download_file_async(
 ) -> bool:
 ) -> bool:
     """Async wrapper for downloading a file with timeout.
     """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:
     Args:
         ip_address: Printer IP address
         ip_address: Printer IP address
         access_code: Printer access code
         access_code: Printer access code
@@ -468,18 +545,47 @@ async def download_file_async(
         printer_model: Printer model for A1-specific workarounds
         printer_model: Printer model for A1-specific workarounds
     """
     """
     loop = asyncio.get_event_loop()
     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():
         if client.connect():
             try:
             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:
             finally:
                 client.disconnect()
                 client.disconnect()
         return False
         return False
 
 
     try:
     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:
     except TimeoutError:
         logger.warning(f"FTP download timed out after {timeout}s for {remote_path}")
         logger.warning(f"FTP download timed out after {timeout}s for {remote_path}")
         return False
         return False
@@ -526,6 +632,9 @@ async def upload_file_async(
 ) -> bool:
 ) -> bool:
     """Async wrapper for uploading a file with timeout and progress callback.
     """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:
     Args:
         ip_address: Printer IP address
         ip_address: Printer IP address
         access_code: Printer access code
         access_code: Printer access code
@@ -537,23 +646,53 @@ async def upload_file_async(
         printer_model: Printer model for A1-specific workarounds
         printer_model: Printer model for A1-specific workarounds
     """
     """
     loop = asyncio.get_event_loop()
     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(
         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():
         if client.connect():
             logger.info(f"FTP connected to {ip_address}")
             logger.info(f"FTP connected to {ip_address}")
             try:
             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:
             finally:
                 client.disconnect()
                 client.disconnect()
         logger.warning(f"FTP connection failed to {ip_address}")
         logger.warning(f"FTP connection failed to {ip_address}")
         return False
         return False
 
 
     try:
     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:
     except TimeoutError:
         logger.warning(f"FTP upload timed out after {timeout}s for {remote_path}")
         logger.warning(f"FTP upload timed out after {timeout}s for {remote_path}")
         return False
         return False

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

@@ -2049,6 +2049,10 @@ class BambuMQTTClient:
                         slot_id = tray_id % 4
                         slot_id = tray_id % 4
                         ams_mapping2.append({"ams_id": ams_id, "slot_id": slot_id})
                         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 = {
             command = {
                 "print": {
                 "print": {
                     "sequence_id": "20000",
                     "sequence_id": "20000",
@@ -2058,13 +2062,13 @@ class BambuMQTTClient:
                     "file": filename,
                     "file": filename,
                     "md5": "",
                     "md5": "",
                     "bed_type": "auto",
                     "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,
                     "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",
                     "cfg": "0",
                     "extrude_cali_flag": 0,
                     "extrude_cali_flag": 0,
                     "extrude_cali_manual_mode": 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-specific parameter adjustments
             # P2S printer doesn't support vibration calibration like X1/P1 series
             # P2S printer doesn't support vibration calibration like X1/P1 series
             if self.model and self.model.upper().strip() in ("P2S", "N7"):
             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,
     output_path: Path,
     timeout: int = 30,
     timeout: int = 30,
 ) -> bool:
 ) -> 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:
     Args:
         ip_address: Printer IP address
         ip_address: Printer IP address
@@ -317,36 +316,57 @@ async def capture_camera_frame(
     Returns:
     Returns:
         True if capture was successful, False otherwise
         True if capture was successful, False otherwise
     """
     """
-    # Ensure output directory exists
     output_path.parent.mkdir(parents=True, exist_ok=True)
     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):
     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)
     camera_url = build_camera_url(ip_address, access_code, model)
 
 
     ffmpeg = get_ffmpeg_path()
     ffmpeg = get_ffmpeg_path()
     if not ffmpeg:
     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 = [
     cmd = [
         ffmpeg,
         ffmpeg,
-        "-y",  # Overwrite output
+        "-y",
         "-rtsp_transport",
         "-rtsp_transport",
         "tcp",
         "tcp",
         "-rtsp_flags",
         "-rtsp_flags",
@@ -355,14 +375,16 @@ async def capture_camera_frame(
         camera_url,
         camera_url,
         "-frames:v",
         "-frames:v",
         "1",
         "1",
-        "-update",
-        "1",
+        "-f",
+        "image2pipe",
+        "-vcodec",
+        "mjpeg",
         "-q:v",
         "-q:v",
         "2",
         "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:
     try:
         process = await asyncio.create_subprocess_exec(
         process = await asyncio.create_subprocess_exec(
@@ -376,27 +398,23 @@ async def capture_camera_frame(
         except TimeoutError:
         except TimeoutError:
             process.kill()
             process.kill()
             await process.wait()
             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:
         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:
     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:
     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(
 async def capture_finish_photo(

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

@@ -9,7 +9,7 @@ import hashlib
 import json
 import json
 import logging
 import logging
 import re
 import re
-from datetime import UTC, datetime, timedelta
+from datetime import datetime, timedelta, timezone
 
 
 import httpx
 import httpx
 from sqlalchemy import desc, select
 from sqlalchemy import desc, select
@@ -85,19 +85,19 @@ class GitHubBackupService:
             )
             )
             configs = result.scalars().all()
             configs = result.scalars().all()
 
 
-            now = datetime.now(UTC)
+            now = datetime.now(timezone.utc)
             for config in configs:
             for config in configs:
                 # Handle both naive (from DB) and aware datetimes
                 # Handle both naive (from DB) and aware datetimes
                 next_run = config.next_scheduled_run
                 next_run = config.next_scheduled_run
                 if next_run and next_run.tzinfo is None:
                 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:
                 if next_run and next_run <= now:
                     logger.info(f"Running scheduled backup for config {config.id}")
                     logger.info(f"Running scheduled backup for config {config.id}")
                     await self.run_backup(config.id, trigger="scheduled")
                     await self.run_backup(config.id, trigger="scheduled")
 
 
     def _calculate_next_run(self, schedule_type: str, from_time: datetime | None = None) -> datetime:
     def _calculate_next_run(self, schedule_type: str, from_time: datetime | None = None) -> datetime:
         """Calculate the next scheduled run time."""
         """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"])
         interval = SCHEDULE_INTERVALS.get(schedule_type, SCHEDULE_INTERVALS["daily"])
         return now + timedelta(seconds=interval)
         return now + timedelta(seconds=interval)
 
 
@@ -237,9 +237,9 @@ class GitHubBackupService:
                     if not backup_data:
                     if not backup_data:
                         # No data to backup
                         # No data to backup
                         log.status = "skipped"
                         log.status = "skipped"
-                        log.completed_at = datetime.now(UTC)
+                        log.completed_at = datetime.now(timezone.utc)
                         log.error_message = "No data to backup"
                         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_status = "skipped"
                         config.last_backup_message = "No data to backup"
                         config.last_backup_message = "No data to backup"
                         if config.schedule_enabled:
                         if config.schedule_enabled:
@@ -259,12 +259,12 @@ class GitHubBackupService:
 
 
                     # Update log and config
                     # Update log and config
                     log.status = push_result["status"]
                     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.commit_sha = push_result.get("commit_sha")
                     log.files_changed = push_result.get("files_changed", 0)
                     log.files_changed = push_result.get("files_changed", 0)
                     log.error_message = push_result.get("error")
                     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_status = push_result["status"]
                     config.last_backup_message = push_result.get("message", "")
                     config.last_backup_message = push_result.get("message", "")
                     config.last_backup_commit_sha = push_result.get("commit_sha")
                     config.last_backup_commit_sha = push_result.get("commit_sha")
@@ -285,10 +285,10 @@ class GitHubBackupService:
                 except Exception as e:
                 except Exception as e:
                     logger.error(f"Backup failed: {e}")
                     logger.error(f"Backup failed: {e}")
                     log.status = "failed"
                     log.status = "failed"
-                    log.completed_at = datetime.now(UTC)
+                    log.completed_at = datetime.now(timezone.utc)
                     log.error_message = str(e)
                     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_status = "failed"
                     config.last_backup_message = str(e)
                     config.last_backup_message = str(e)
 
 
@@ -572,7 +572,7 @@ class GitHubBackupService:
             new_tree_sha = tree_response.json()["sha"]
             new_tree_sha = tree_response.json()["sha"]
 
 
             # Create commit
             # 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(
             commit_response = await client.post(
                 f"https://api.github.com/repos/{owner}/{repo}/git/commits",
                 f"https://api.github.com/repos/{owner}/{repo}/git/commits",
                 headers=headers,
                 headers=headers,
@@ -689,7 +689,7 @@ class GitHubBackupService:
                 f"https://api.github.com/repos/{owner}/{repo}/git/commits",
                 f"https://api.github.com/repos/{owner}/{repo}/git/commits",
                 headers=headers,
                 headers=headers,
                 json={
                 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,
                     "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:
         else:
             return False, f"HTTP {response.status_code}: {response.text[:200]}"
             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()
         user_key = config.get("user_key", "").strip()
         app_token = config.get("app_token", "").strip()
         app_token = config.get("app_token", "").strip()
         priority = config.get("priority", 0)
         priority = config.get("priority", 0)
@@ -230,7 +239,13 @@ class NotificationService:
         }
         }
 
 
         client = await self._get_client()
         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:
         if response.status_code == 200:
             return True, "Message sent successfully"
             return True, "Message sent successfully"
@@ -407,7 +422,12 @@ class NotificationService:
             return False, f"Webhook error: {str(e)}"
             return False, f"Webhook error: {str(e)}"
 
 
     async def _send_to_provider(
     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]:
     ) -> tuple[bool, str]:
         """Send notification to a specific provider."""
         """Send notification to a specific provider."""
         # Check quiet hours
         # Check quiet hours
@@ -423,7 +443,7 @@ class NotificationService:
             elif provider.provider_type == "ntfy":
             elif provider.provider_type == "ntfy":
                 return await self._send_ntfy(config, title, message)
                 return await self._send_ntfy(config, title, message)
             elif provider.provider_type == "pushover":
             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":
             elif provider.provider_type == "telegram":
                 return await self._send_telegram(config, f"*{title}*\n{message}")
                 return await self._send_telegram(config, f"*{title}*\n{message}")
             elif provider.provider_type == "email":
             elif provider.provider_type == "email":
@@ -513,6 +533,7 @@ class NotificationService:
         printer_id: int | None = None,
         printer_id: int | None = None,
         printer_name: str | None = None,
         printer_name: str | None = None,
         force_immediate: bool = False,
         force_immediate: bool = False,
+        image_data: bytes | None = None,
     ):
     ):
         """Send notification to multiple providers and log the results.
         """Send notification to multiple providers and log the results.
 
 
@@ -522,7 +543,7 @@ class NotificationService:
         for provider in providers:
         for provider in providers:
             try:
             try:
                 # Always send notification immediately
                 # 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)
                 # Also queue for digest if enabled (digest is a summary, not a queue)
                 if provider.daily_digest_enabled and provider.daily_digest_time:
                 if provider.daily_digest_enabled and provider.daily_digest_time:
@@ -629,9 +650,16 @@ class NotificationService:
             "estimated_time": time_str,
             "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]}")
         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)
         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(
     async def on_print_complete(
         self,
         self,
@@ -690,11 +718,16 @@ class NotificationService:
             if archive_data.get("finish_photo_url"):
             if archive_data.get("finish_photo_url"):
                 variables["finish_photo_url"] = archive_data["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]}")
         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)
         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(
     async def on_print_progress(
         self,
         self,
@@ -704,6 +737,7 @@ class NotificationService:
         progress: int,
         progress: int,
         db: AsyncSession,
         db: AsyncSession,
         remaining_time: int | None = None,
         remaining_time: int | None = None,
+        image_data: bytes | None = None,
     ):
     ):
         """Handle print progress milestone (25%, 50%, 75%)."""
         """Handle print progress milestone (25%, 50%, 75%)."""
         providers = await self._get_providers_for_event(db, "on_print_progress", printer_id)
         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)
         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):
     async def on_printer_offline(self, printer_id: int, printer_name: str, db: AsyncSession):
         """Handle printer offline event."""
         """Handle printer offline event."""
@@ -738,6 +774,7 @@ class NotificationService:
         error_type: str,
         error_type: str,
         db: AsyncSession,
         db: AsyncSession,
         error_detail: str | None = None,
         error_detail: str | None = None,
+        image_data: bytes | None = None,
     ):
     ):
         """Handle printer error event (AMS issues, etc.)."""
         """Handle printer error event (AMS issues, etc.)."""
         providers = await self._get_providers_for_event(db, "on_printer_error", printer_id)
         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)
         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(
     async def on_plate_not_empty(
         self,
         self,

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

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

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

@@ -2,7 +2,7 @@
 
 
 import logging
 import logging
 from dataclasses import dataclass
 from dataclasses import dataclass
-from datetime import UTC, datetime
+from datetime import datetime, timezone
 
 
 import httpx
 import httpx
 
 
@@ -355,7 +355,7 @@ class SpoolmanClient:
                 data["extra"] = extra
                 data["extra"] = extra
 
 
             # Always update last_used
             # Always update last_used
-            data["last_used"] = datetime.now(UTC).isoformat()
+            data["last_used"] = datetime.now(timezone.utc).isoformat()
 
 
             client = await self._get_client()
             client = await self._get_client()
             response = await client.patch(f"{self.api_url}/spool/{spool_id}", json=data)
             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 logging
 import socket
 import socket
-from datetime import UTC, datetime, timedelta
+from datetime import datetime, timedelta, timezone
 from ipaddress import IPv4Address
 from ipaddress import IPv4Address
 from pathlib import Path
 from pathlib import Path
 
 
@@ -89,7 +89,7 @@ class CertificateService:
             ca_cert = x509.load_pem_x509_certificate(ca_cert_pem)
             ca_cert = x509.load_pem_x509_certificate(ca_cert_pem)
 
 
             # Check if CA is expired or about to expire
             # 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
             days_remaining = (ca_cert.not_valid_after_utc - now).days
             if days_remaining < CA_EXPIRY_THRESHOLD_DAYS:
             if days_remaining < CA_EXPIRY_THRESHOLD_DAYS:
                 logger.warning(f"CA certificate expires in {days_remaining} days, will regenerate")
                 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 = (
         ca_cert = (
             x509.CertificateBuilder()
             x509.CertificateBuilder()
@@ -227,7 +227,7 @@ class CertificateService:
         # Issuer is the CA
         # Issuer is the CA
         issuer = ca_cert.subject
         issuer = ca_cert.subject
 
 
-        now = datetime.now(UTC)
+        now = datetime.now(timezone.utc)
         local_ip = _get_local_ip()
         local_ip = _get_local_ip()
         logger.info(f"Generating printer certificate with CN={self.serial}, local IP: {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 asyncio
 import logging
 import logging
 from collections.abc import Callable
 from collections.abc import Callable
-from datetime import UTC, datetime
+from datetime import datetime, timezone
 from pathlib import Path
 from pathlib import Path
 
 
 from backend.app.core.config import settings as app_settings
 from backend.app.core.config import settings as app_settings
 from backend.app.services.virtual_printer.certificate import CertificateService
 from backend.app.services.virtual_printer.certificate import CertificateService
 from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPServer
 from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPServer
 from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer
 from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer
-from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
+from backend.app.services.virtual_printer.ssdp_server import SSDPProxy, VirtualPrinterSSDPServer
 from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager
 from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -93,9 +93,11 @@ class VirtualPrinterManager:
         self._model = DEFAULT_VIRTUAL_PRINTER_MODEL
         self._model = DEFAULT_VIRTUAL_PRINTER_MODEL
         self._target_printer_ip = ""  # For proxy mode
         self._target_printer_ip = ""  # For proxy mode
         self._target_printer_serial = ""  # For proxy mode (real printer's serial)
         self._target_printer_serial = ""  # For proxy mode (real printer's serial)
+        self._remote_interface_ip = ""  # For proxy mode SSDP (LAN B - slicer network)
 
 
         # Service instances
         # Service instances
         self._ssdp: VirtualPrinterSSDPServer | None = None
         self._ssdp: VirtualPrinterSSDPServer | None = None
+        self._ssdp_proxy: SSDPProxy | None = None
         self._ftp: VirtualPrinterFTPServer | None = None
         self._ftp: VirtualPrinterFTPServer | None = None
         self._mqtt: SimpleMQTTServer | None = None
         self._mqtt: SimpleMQTTServer | None = None
         self._proxy: SlicerProxyManager | None = None  # For proxy mode
         self._proxy: SlicerProxyManager | None = None  # For proxy mode
@@ -108,12 +110,54 @@ class VirtualPrinterManager:
         self._upload_dir = self._base_dir / "uploads"
         self._upload_dir = self._base_dir / "uploads"
         self._cert_dir = self._base_dir / "certs"
         self._cert_dir = self._base_dir / "certs"
 
 
+        # Create directories early to avoid permission issues later
+        # If running in Docker, these need to be on a writable volume
+        self._ensure_directories()
+
         # Certificate service
         # Certificate service
         self._cert_service = CertificateService(self._cert_dir)
         self._cert_service = CertificateService(self._cert_dir)
 
 
         # Track pending uploads for MQTT correlation
         # Track pending uploads for MQTT correlation
         self._pending_files: dict[str, Path] = {}
         self._pending_files: dict[str, Path] = {}
 
 
+    def _ensure_directories(self) -> None:
+        """Create and verify virtual printer directories are writable.
+
+        Creates all required directories at startup to catch permission
+        issues early rather than when the user tries to enable features.
+        """
+        dirs_to_create = [
+            self._base_dir,
+            self._upload_dir,
+            self._upload_dir / "cache",
+            self._cert_dir,
+        ]
+
+        logger.info(f"Checking virtual printer directories in {self._base_dir}")
+
+        for dir_path in dirs_to_create:
+            try:
+                dir_path.mkdir(parents=True, exist_ok=True)
+            except PermissionError:
+                logger.error(
+                    f"Cannot create directory {dir_path}: Permission denied. "
+                    f"For Docker: ensure the data volume is writable by the container user. "
+                    f"For bare metal: run 'sudo chown -R $(whoami) {self._base_dir}'"
+                )
+                continue
+
+            # Verify directory is writable by attempting to create a test file
+            test_file = dir_path / ".write_test"
+            try:
+                test_file.touch()
+                test_file.unlink(missing_ok=True)
+            except PermissionError:
+                logger.error(
+                    f"Directory {dir_path} exists but is not writable. "
+                    f"For Docker: ensure the data volume is writable by the container user (uid/gid). "
+                    f"For bare metal: run 'sudo chown -R $(whoami) {self._base_dir}'"
+                )
+
     def _get_serial_for_model(self, model: str) -> str:
     def _get_serial_for_model(self, model: str) -> str:
         """Get appropriate serial number for the given model.
         """Get appropriate serial number for the given model.
 
 
@@ -157,6 +201,7 @@ class VirtualPrinterManager:
         model: str = "",
         model: str = "",
         target_printer_ip: str = "",
         target_printer_ip: str = "",
         target_printer_serial: str = "",
         target_printer_serial: str = "",
+        remote_interface_ip: str = "",
     ) -> None:
     ) -> None:
         """Configure and start/stop virtual printer.
         """Configure and start/stop virtual printer.
 
 
@@ -167,6 +212,7 @@ class VirtualPrinterManager:
             model: SSDP model code (e.g., 'BL-P001' for X1C)
             model: SSDP model code (e.g., 'BL-P001' for X1C)
             target_printer_ip: Target printer IP for proxy mode
             target_printer_ip: Target printer IP for proxy mode
             target_printer_serial: Target printer serial for proxy mode
             target_printer_serial: Target printer serial for proxy mode
+            remote_interface_ip: IP of interface on slicer network (LAN B) for SSDP proxy
         """
         """
         # Proxy mode has different requirements
         # Proxy mode has different requirements
         if mode == "proxy":
         if mode == "proxy":
@@ -183,12 +229,14 @@ class VirtualPrinterManager:
         mode_changed = mode != self._mode
         mode_changed = mode != self._mode
         target_changed = target_printer_ip != self._target_printer_ip
         target_changed = target_printer_ip != self._target_printer_ip
         serial_changed = target_printer_serial != self._target_printer_serial
         serial_changed = target_printer_serial != self._target_printer_serial
+        remote_iface_changed = remote_interface_ip != self._remote_interface_ip
         old_mode = self._mode
         old_mode = self._mode
 
 
         logger.debug(
         logger.debug(
             f"configure() called: enabled={enabled}, self._enabled={self._enabled}, "
             f"configure() called: enabled={enabled}, self._enabled={self._enabled}, "
             f"mode={mode}, old_mode={old_mode}, model={model}, new_model={new_model}, "
             f"mode={mode}, old_mode={old_mode}, model={model}, new_model={new_model}, "
-            f"target_printer_ip={target_printer_ip}, target_printer_serial={target_printer_serial}"
+            f"target_printer_ip={target_printer_ip}, target_printer_serial={target_printer_serial}, "
+            f"remote_interface_ip={remote_interface_ip}"
         )
         )
 
 
         self._access_code = access_code
         self._access_code = access_code
@@ -196,8 +244,13 @@ class VirtualPrinterManager:
         self._model = new_model
         self._model = new_model
         self._target_printer_ip = target_printer_ip
         self._target_printer_ip = target_printer_ip
         self._target_printer_serial = target_printer_serial
         self._target_printer_serial = target_printer_serial
+        self._remote_interface_ip = remote_interface_ip
 
 
-        needs_restart = model_changed or mode_changed or (mode == "proxy" and (target_changed or serial_changed))
+        needs_restart = (
+            model_changed
+            or mode_changed
+            or (mode == "proxy" and (target_changed or serial_changed or remote_iface_changed))
+        )
 
 
         if enabled and not self._enabled:
         if enabled and not self._enabled:
             logger.info("Starting virtual printer (was disabled)")
             logger.info("Starting virtual printer (was disabled)")
@@ -247,13 +300,6 @@ class VirtualPrinterManager:
         cert_path, key_path = self._cert_service.generate_certificates()
         cert_path, key_path = self._cert_service.generate_certificates()
         logger.info(f"Generated certificate for proxy serial: {proxy_serial}")
         logger.info(f"Generated certificate for proxy serial: {proxy_serial}")
 
 
-        # Initialize SSDP for local discovery using the real printer's serial
-        self._ssdp = VirtualPrinterSSDPServer(
-            name=f"{self.PRINTER_NAME} (Proxy)",
-            serial=proxy_serial,
-            model=self._model,
-        )
-
         # Initialize TLS proxy with our certificates
         # Initialize TLS proxy with our certificates
         self._proxy = SlicerProxyManager(
         self._proxy = SlicerProxyManager(
             target_host=self._target_printer_ip,
             target_host=self._target_printer_ip,
@@ -269,21 +315,68 @@ class VirtualPrinterManager:
             except Exception as e:
             except Exception as e:
                 logger.error(f"Virtual printer {name} failed: {e}")
                 logger.error(f"Virtual printer {name} failed: {e}")
 
 
-        self._tasks = [
-            asyncio.create_task(
-                run_with_logging(self._ssdp.start(), "SSDP"),
-                name="virtual_printer_ssdp",
-            ),
+        self._tasks = []
+
+        # SSDP setup: use SSDPProxy if remote interface is configured
+        # Local interface is auto-detected from target printer IP
+        if self._remote_interface_ip:
+            # Auto-detect local interface based on target printer IP
+            from backend.app.services.network_utils import find_interface_for_ip
+
+            local_iface = find_interface_for_ip(self._target_printer_ip)
+            if local_iface:
+                local_interface_ip = local_iface["ip"]
+                logger.info(
+                    f"SSDP proxy mode: LAN A ({local_interface_ip}, auto-detected) -> LAN B ({self._remote_interface_ip})"
+                )
+                self._ssdp_proxy = SSDPProxy(
+                    local_interface_ip=local_interface_ip,
+                    remote_interface_ip=self._remote_interface_ip,
+                    target_printer_ip=self._target_printer_ip,
+                )
+                self._tasks.append(
+                    asyncio.create_task(
+                        run_with_logging(self._ssdp_proxy.start(), "SSDP Proxy"),
+                        name="virtual_printer_ssdp_proxy",
+                    )
+                )
+            else:
+                logger.warning(
+                    f"Could not auto-detect local interface for printer {self._target_printer_ip}, "
+                    "falling back to single-interface SSDP"
+                )
+                self._start_fallback_ssdp(proxy_serial, run_with_logging)
+        else:
+            # Single interface: broadcast SSDP on same network (fallback)
+            self._start_fallback_ssdp(proxy_serial, run_with_logging)
+
+        # Add TLS proxy task
+        self._tasks.append(
             asyncio.create_task(
             asyncio.create_task(
                 run_with_logging(self._proxy.start(), "Proxy"),
                 run_with_logging(self._proxy.start(), "Proxy"),
                 name="virtual_printer_proxy",
                 name="virtual_printer_proxy",
-            ),
-        ]
+            )
+        )
 
 
         logger.info(
         logger.info(
             f"Virtual printer proxy started: "
             f"Virtual printer proxy started: "
-            f"FTP 0.0.0.0:{SlicerProxyManager.LOCAL_FTP_PORT} → {self._target_printer_ip}:{SlicerProxyManager.PRINTER_FTP_PORT}, "
-            f"MQTT 0.0.0.0:{SlicerProxyManager.LOCAL_MQTT_PORT} → {self._target_printer_ip}:{SlicerProxyManager.PRINTER_MQTT_PORT}"
+            f"FTP 0.0.0.0:{SlicerProxyManager.LOCAL_FTP_PORT} -> {self._target_printer_ip}:{SlicerProxyManager.PRINTER_FTP_PORT}, "
+            f"MQTT 0.0.0.0:{SlicerProxyManager.LOCAL_MQTT_PORT} -> {self._target_printer_ip}:{SlicerProxyManager.PRINTER_MQTT_PORT}"
+        )
+
+    def _start_fallback_ssdp(self, proxy_serial: str, run_with_logging) -> None:
+        """Start single-interface SSDP server as fallback."""
+        logger.info("SSDP broadcast mode (single interface)")
+        self._ssdp = VirtualPrinterSSDPServer(
+            name=f"{self.PRINTER_NAME} (Proxy)",
+            serial=proxy_serial,
+            model=self._model,
+        )
+        self._tasks.append(
+            asyncio.create_task(
+                run_with_logging(self._ssdp.start(), "SSDP"),
+                name="virtual_printer_ssdp",
+            )
         )
         )
 
 
     async def _start_server_mode(self) -> None:
     async def _start_server_mode(self) -> None:
@@ -361,6 +454,10 @@ class VirtualPrinterManager:
             await self._ssdp.stop()
             await self._ssdp.stop()
             self._ssdp = None
             self._ssdp = None
 
 
+        if self._ssdp_proxy:
+            await self._ssdp_proxy.stop()
+            self._ssdp_proxy = None
+
         if self._proxy:
         if self._proxy:
             await self._proxy.stop()
             await self._proxy.stop()
             self._proxy = None
             self._proxy = None
@@ -501,7 +598,7 @@ class VirtualPrinterManager:
                     file_size=file_path.stat().st_size,
                     file_size=file_path.stat().st_size,
                     source_ip=source_ip,
                     source_ip=source_ip,
                     status="pending",
                     status="pending",
-                    uploaded_at=datetime.now(UTC),
+                    uploaded_at=datetime.now(timezone.utc),
                 )
                 )
                 db.add(pending)
                 db.add(pending)
                 await db.commit()
                 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
 Responds to M-SEARCH requests from slicers and sends periodic NOTIFY
 announcements so the virtual printer appears as a discoverable Bambu printer.
 announcements so the virtual printer appears as a discoverable Bambu printer.
+
+Also provides SSDP proxy functionality for proxy mode, where Bambuddy sits
+between two networks and re-broadcasts printer SSDP from LAN A to LAN B.
 """
 """
 
 
 import asyncio
 import asyncio
 import logging
 import logging
+import re
 import socket
 import socket
 import struct
 import struct
-from datetime import datetime
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
-# SSDP multicast address - Bambu uses port 2021
-SSDP_ADDR = "239.255.255.250"
+# SSDP addresses - Bambu uses port 2021
+# Real Bambu printers broadcast to 255.255.255.255, not multicast to 239.255.255.250
+SSDP_MULTICAST_ADDR = "239.255.255.250"
+SSDP_BROADCAST_ADDR = "255.255.255.255"
 SSDP_PORT = 2021
 SSDP_PORT = 2021
 
 
 # Bambu service target
 # Bambu service target
@@ -60,44 +65,49 @@ class VirtualPrinterSSDPServer:
             return "127.0.0.1"
             return "127.0.0.1"
 
 
     def _build_notify_message(self) -> bytes:
     def _build_notify_message(self) -> bytes:
-        """Build SSDP NOTIFY message for periodic announcements."""
+        """Build SSDP NOTIFY message for periodic announcements.
+
+        Format matches real Bambu printer SSDP broadcasts observed on the network.
+        Real printers use Host: 239.255.255.250:1990 (port 1990 in header).
+        """
         ip = self._get_local_ip()
         ip = self._get_local_ip()
-        # Based on: https://gist.github.com/Alex-Schaefer/72a9e2491a42da2ef99fb87601955cc3
+        # Match exact format of real Bambu printers (captured via tcpdump)
         # Key: DevBind.bambu.com: free - tells slicer printer is NOT cloud-bound
         # Key: DevBind.bambu.com: free - tells slicer printer is NOT cloud-bound
         message = (
         message = (
             "NOTIFY * HTTP/1.1\r\n"
             "NOTIFY * HTTP/1.1\r\n"
-            f"HOST: {SSDP_ADDR}:{SSDP_PORT}\r\n"
-            "Server: Buildroot/2018.02-rc3 UPnP/1.0 ssdpd/1.8\r\n"
-            "Cache-Control: max-age=1800\r\n"
+            f"Host: {SSDP_MULTICAST_ADDR}:1990\r\n"
+            "Server: UPnP/1.0\r\n"
             f"Location: {ip}\r\n"
             f"Location: {ip}\r\n"
             f"NT: {BAMBU_SEARCH_TARGET}\r\n"
             f"NT: {BAMBU_SEARCH_TARGET}\r\n"
             "NTS: ssdp:alive\r\n"
             "NTS: ssdp:alive\r\n"
-            "EXT:\r\n"
             f"USN: {self.serial}\r\n"
             f"USN: {self.serial}\r\n"
+            "Cache-Control: max-age=1800\r\n"
             f"DevModel.bambu.com: {self.model}\r\n"
             f"DevModel.bambu.com: {self.model}\r\n"
             f"DevName.bambu.com: {self.name}\r\n"
             f"DevName.bambu.com: {self.name}\r\n"
             "DevSignal.bambu.com: -44\r\n"
             "DevSignal.bambu.com: -44\r\n"
             "DevConnect.bambu.com: lan\r\n"
             "DevConnect.bambu.com: lan\r\n"
             "DevBind.bambu.com: free\r\n"
             "DevBind.bambu.com: free\r\n"
             "Devseclink.bambu.com: secure\r\n"
             "Devseclink.bambu.com: secure\r\n"
+            "DevInf.bambu.com: eth0\r\n"
             "DevVersion.bambu.com: 01.07.00.00\r\n"
             "DevVersion.bambu.com: 01.07.00.00\r\n"
+            "DevCap.bambu.com: 1\r\n"
             "\r\n"
             "\r\n"
         )
         )
         return message.encode()
         return message.encode()
 
 
     def _build_response_message(self) -> bytes:
     def _build_response_message(self) -> bytes:
-        """Build SSDP response message for M-SEARCH requests."""
+        """Build SSDP response message for M-SEARCH requests.
+
+        Format matches real Bambu printer SSDP responses.
+        """
         ip = self._get_local_ip()
         ip = self._get_local_ip()
-        # Based on: https://gist.github.com/Alex-Schaefer/72a9e2491a42da2ef99fb87601955cc3
+        # Match format of real Bambu printers
         # Key: DevBind.bambu.com: free - tells slicer printer is NOT cloud-bound
         # Key: DevBind.bambu.com: free - tells slicer printer is NOT cloud-bound
-        # Added: Devseclink, DevVersion, DevCap for better compatibility
         message = (
         message = (
             "HTTP/1.1 200 OK\r\n"
             "HTTP/1.1 200 OK\r\n"
-            "Server: Buildroot/2018.02-rc3 UPnP/1.0 ssdpd/1.8\r\n"
-            f"Date: {datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT')}\r\n"
+            "Server: UPnP/1.0\r\n"
             f"Location: {ip}\r\n"
             f"Location: {ip}\r\n"
             f"ST: {BAMBU_SEARCH_TARGET}\r\n"
             f"ST: {BAMBU_SEARCH_TARGET}\r\n"
-            "EXT:\r\n"
             f"USN: {self.serial}\r\n"
             f"USN: {self.serial}\r\n"
             "Cache-Control: max-age=1800\r\n"
             "Cache-Control: max-age=1800\r\n"
             f"DevModel.bambu.com: {self.model}\r\n"
             f"DevModel.bambu.com: {self.model}\r\n"
@@ -106,7 +116,9 @@ class VirtualPrinterSSDPServer:
             "DevConnect.bambu.com: lan\r\n"
             "DevConnect.bambu.com: lan\r\n"
             "DevBind.bambu.com: free\r\n"
             "DevBind.bambu.com: free\r\n"
             "Devseclink.bambu.com: secure\r\n"
             "Devseclink.bambu.com: secure\r\n"
+            "DevInf.bambu.com: eth0\r\n"
             "DevVersion.bambu.com: 01.07.00.00\r\n"
             "DevVersion.bambu.com: 01.07.00.00\r\n"
+            "DevCap.bambu.com: 1\r\n"
             "\r\n"
             "\r\n"
         )
         )
         return message.encode()
         return message.encode()
@@ -137,7 +149,7 @@ class VirtualPrinterSSDPServer:
             self._socket.bind(("", SSDP_PORT))
             self._socket.bind(("", SSDP_PORT))
 
 
             # Join multicast group
             # Join multicast group
-            mreq = struct.pack("4sl", socket.inet_aton(SSDP_ADDR), socket.INADDR_ANY)
+            mreq = struct.pack("4sl", socket.inet_aton(SSDP_MULTICAST_ADDR), socket.INADDR_ANY)
             self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
             self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
 
 
             # Enable broadcast
             # Enable broadcast
@@ -212,13 +224,14 @@ class VirtualPrinterSSDPServer:
             self._socket = None
             self._socket = None
 
 
     async def _send_notify(self) -> None:
     async def _send_notify(self) -> None:
-        """Send SSDP NOTIFY message."""
+        """Send SSDP NOTIFY message via broadcast (like real Bambu printers)."""
         if not self._socket:
         if not self._socket:
             return
             return
 
 
         try:
         try:
             msg = self._build_notify_message()
             msg = self._build_notify_message()
-            self._socket.sendto(msg, (SSDP_ADDR, SSDP_PORT))
+            # Real Bambu printers broadcast to 255.255.255.255, not multicast
+            self._socket.sendto(msg, (SSDP_BROADCAST_ADDR, SSDP_PORT))
             logger.debug(f"Sent SSDP NOTIFY for {self.name}")
             logger.debug(f"Sent SSDP NOTIFY for {self.name}")
         except Exception as e:
         except Exception as e:
             logger.debug(f"Failed to send NOTIFY: {e}")
             logger.debug(f"Failed to send NOTIFY: {e}")
@@ -230,7 +243,7 @@ class VirtualPrinterSSDPServer:
 
 
         message = (
         message = (
             "NOTIFY * HTTP/1.1\r\n"
             "NOTIFY * HTTP/1.1\r\n"
-            f"HOST: {SSDP_ADDR}:{SSDP_PORT}\r\n"
+            f"Host: {SSDP_MULTICAST_ADDR}:1990\r\n"
             f"NT: {BAMBU_SEARCH_TARGET}\r\n"
             f"NT: {BAMBU_SEARCH_TARGET}\r\n"
             "NTS: ssdp:byebye\r\n"
             "NTS: ssdp:byebye\r\n"
             f"USN: {self.serial}\r\n"
             f"USN: {self.serial}\r\n"
@@ -238,7 +251,7 @@ class VirtualPrinterSSDPServer:
         )
         )
 
 
         try:
         try:
-            self._socket.sendto(message.encode(), (SSDP_ADDR, SSDP_PORT))
+            self._socket.sendto(message.encode(), (SSDP_BROADCAST_ADDR, SSDP_PORT))
             logger.debug("Sent SSDP byebye")
             logger.debug("Sent SSDP byebye")
         except Exception:
         except Exception:
             pass
             pass
@@ -268,3 +281,215 @@ class VirtualPrinterSSDPServer:
                 logger.info(f"Sent SSDP response to {addr[0]} for virtual printer '{self.name}'")
                 logger.info(f"Sent SSDP response to {addr[0]} for virtual printer '{self.name}'")
             except Exception as e:
             except Exception as e:
                 logger.debug(f"Failed to send SSDP response: {e}")
                 logger.debug(f"Failed to send SSDP response: {e}")
+
+
+class SSDPProxy:
+    """SSDP proxy that re-broadcasts printer discovery from one network to another.
+
+    Listens for SSDP broadcasts from a real printer on the local interface (LAN A),
+    then re-broadcasts them on the remote interface (LAN B) with the Location
+    header changed to point to Bambuddy's IP on LAN B.
+
+    This allows Bambu Studio on LAN B to discover the printer via Bambuddy.
+    """
+
+    def __init__(
+        self,
+        local_interface_ip: str,
+        remote_interface_ip: str,
+        target_printer_ip: str,
+    ):
+        """Initialize the SSDP proxy.
+
+        Args:
+            local_interface_ip: IP of interface on printer's network (LAN A)
+            remote_interface_ip: IP of interface on slicer's network (LAN B)
+            target_printer_ip: IP of the real printer to proxy SSDP for
+        """
+        self.local_interface_ip = local_interface_ip
+        self.remote_interface_ip = remote_interface_ip
+        self.target_printer_ip = target_printer_ip
+        self._running = False
+        self._local_socket: socket.socket | None = None
+        self._remote_socket: socket.socket | None = None
+        self._last_printer_ssdp: bytes | None = None
+        self._printer_info: dict[str, str] = {}
+
+    def _parse_ssdp_message(self, data: bytes) -> dict[str, str]:
+        """Parse SSDP message into header dict."""
+        headers = {}
+        try:
+            text = data.decode("utf-8", errors="ignore")
+            for line in text.split("\r\n"):
+                if ":" in line:
+                    key, value = line.split(":", 1)
+                    headers[key.strip().lower()] = value.strip()
+        except Exception:
+            pass
+        return headers
+
+    def _rewrite_ssdp_location(self, data: bytes) -> bytes:
+        """Rewrite SSDP message with Bambuddy's remote IP as Location."""
+        try:
+            text = data.decode("utf-8", errors="ignore")
+            original = text
+            # Replace Location header with our remote interface IP
+            text = re.sub(
+                r"(Location:\s*)[\d.]+",
+                f"\\g<1>{self.remote_interface_ip}",
+                text,
+                flags=re.IGNORECASE,
+            )
+            if text != original:
+                logger.debug(f"Rewrote SSDP Location to {self.remote_interface_ip}")
+                logger.debug(f"Rewritten SSDP packet:\n{text}")
+            else:
+                logger.warning(f"SSDP Location rewrite had no effect. Packet:\n{original}")
+            return text.encode("utf-8")
+        except Exception as e:
+            logger.error(f"Failed to rewrite SSDP: {e}")
+            return data
+
+    async def start(self) -> None:
+        """Start the SSDP proxy."""
+        if self._running:
+            return
+
+        logger.info(
+            f"Starting SSDP proxy: listening on {self.local_interface_ip} (LAN A), "
+            f"broadcasting on {self.remote_interface_ip} (LAN B), "
+            f"proxying printer {self.target_printer_ip}"
+        )
+        self._running = True
+
+        try:
+            # Create socket for listening on LAN A (printer network)
+            # Bind to 0.0.0.0 to receive broadcast packets (255.255.255.255)
+            # We filter by source IP in the handler
+            self._local_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
+            self._local_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+            try:
+                self._local_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
+            except (AttributeError, OSError):
+                pass
+            self._local_socket.setblocking(False)
+            # Bind to all interfaces to receive broadcasts
+            self._local_socket.bind(("", SSDP_PORT))
+
+            # Join multicast group on local interface (for multicast SSDP if used)
+            mreq = struct.pack(
+                "4s4s",
+                socket.inet_aton(SSDP_MULTICAST_ADDR),
+                socket.inet_aton(self.local_interface_ip),
+            )
+            self._local_socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
+            self._local_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+
+            # Create socket for broadcasting on LAN B (slicer network)
+            self._remote_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
+            self._remote_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+            try:
+                self._remote_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
+            except (AttributeError, OSError):
+                pass
+            self._remote_socket.setblocking(False)
+            # Bind to remote interface
+            self._remote_socket.bind((self.remote_interface_ip, 0))
+            self._remote_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+
+            logger.info(f"SSDP proxy listening on 0.0.0.0:{SSDP_PORT} (filtering for printer {self.target_printer_ip})")
+            logger.info(f"SSDP proxy will broadcast on {self.remote_interface_ip}")
+
+            # Main loop
+            last_broadcast = 0.0
+            broadcast_interval = 30.0  # Re-broadcast every 30 seconds
+
+            while self._running:
+                # Listen for SSDP from printer on LAN A
+                try:
+                    data, addr = self._local_socket.recvfrom(4096)
+                    await self._handle_local_packet(data, addr)
+                except BlockingIOError:
+                    pass
+                except Exception as e:
+                    if self._running:
+                        logger.debug(f"SSDP proxy receive error: {e}")
+
+                # Listen for M-SEARCH from slicer on LAN B (via remote socket would need separate bind)
+                # For now, we periodically re-broadcast cached printer SSDP
+                now = asyncio.get_event_loop().time()
+                if self._last_printer_ssdp and now - last_broadcast >= broadcast_interval:
+                    await self._broadcast_to_remote()
+                    last_broadcast = now
+
+                await asyncio.sleep(0.1)
+
+        except OSError as e:
+            logger.error(f"SSDP proxy error: {e}")
+        except asyncio.CancelledError:
+            logger.debug("SSDP proxy cancelled")
+        except Exception as e:
+            logger.error(f"SSDP proxy error: {e}")
+        finally:
+            await self._cleanup()
+
+    async def stop(self) -> None:
+        """Stop the SSDP proxy."""
+        logger.info("Stopping SSDP proxy")
+        self._running = False
+        await self._cleanup()
+
+    async def _cleanup(self) -> None:
+        """Clean up resources."""
+        for sock in [self._local_socket, self._remote_socket]:
+            if sock:
+                try:
+                    sock.close()
+                except Exception:
+                    pass
+        self._local_socket = None
+        self._remote_socket = None
+
+    async def _handle_local_packet(self, data: bytes, addr: tuple[str, int]) -> None:
+        """Handle SSDP packet received on local interface (LAN A)."""
+        sender_ip = addr[0]
+
+        # Only process packets from the target printer
+        if sender_ip != self.target_printer_ip:
+            return
+
+        # Check if it's a NOTIFY message
+        if b"NOTIFY" not in data and b"HTTP/1.1 200" not in data:
+            return
+
+        # Check if it's a Bambu printer SSDP
+        if b"bambulab-com:device:3dprinter" not in data:
+            return
+
+        # Parse and store printer info
+        headers = self._parse_ssdp_message(data)
+        if headers:
+            self._printer_info = headers
+            logger.debug(f"Received SSDP from printer {sender_ip}: {headers.get('devname.bambu.com', 'unknown')}")
+
+        # Store and immediately broadcast
+        self._last_printer_ssdp = data
+        await self._broadcast_to_remote()
+
+    async def _broadcast_to_remote(self) -> None:
+        """Broadcast cached printer SSDP on remote interface (LAN B)."""
+        if not self._remote_socket or not self._last_printer_ssdp:
+            return
+
+        try:
+            # Rewrite Location to point to Bambuddy's remote interface
+            rewritten = self._rewrite_ssdp_location(self._last_printer_ssdp)
+
+            # Calculate broadcast address for remote network
+            # Use 255.255.255.255 for simplicity (works across subnets)
+            self._remote_socket.sendto(rewritten, (SSDP_BROADCAST_ADDR, SSDP_PORT))
+
+            printer_name = self._printer_info.get("devname.bambu.com", "unknown")
+            logger.debug(f"Broadcast SSDP for '{printer_name}' on LAN B ({self.remote_interface_ip})")
+        except Exception as e:
+            logger.debug(f"Failed to broadcast SSDP on remote: {e}")

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

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

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

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

+ 1 - 1
docker-compose.test.yml

@@ -56,7 +56,7 @@ services:
     environment:
     environment:
       - BAMBUDDY_TEST_URL=http://integration:8000
       - BAMBUDDY_TEST_URL=http://integration:8000
       - TESTING=1
       - 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:
     volumes:
       - ./backend:/app/backend:ro
       - ./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          → pulls pre-built image from ghcr.io
     #   docker compose up -d --build  → builds locally from source
     #   docker compose up -d --build  → builds locally from source
     container_name: bambuddy
     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
     # LINUX: Use host mode for printer discovery and camera streaming
     network_mode: host
     network_mode: host

+ 7 - 2
frontend/public/sw.js

@@ -1,6 +1,6 @@
 // Bambuddy Service Worker
 // 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
 // Static assets to cache on install
 const STATIC_ASSETS = [
 const STATIC_ASSETS = [
@@ -62,6 +62,11 @@ self.addEventListener('fetch', (event) => {
     return;
     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)
   // API requests - network first, no cache (real-time data is critical)
   if (url.pathname.startsWith('/api/')) {
   if (url.pathname.startsWith('/api/')) {
     event.respondWith(
     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();
     expect(capturedHeaders!.get('Authorization')).toBeNull();
   });
   });
 
 
-  it('clears token on 401 Unauthorized response', async () => {
+  it('clears token on 401 with invalid token message', async () => {
     server.use(
     server.use(
       http.get('/api/v1/settings/spoolman', () => {
       http.get('/api/v1/settings/spoolman', () => {
         return HttpResponse.json(
         return HttpResponse.json(
-          { detail: 'Not authenticated' },
+          { detail: 'Could not validate credentials' },
           { status: 401 }
           { status: 401 }
         );
         );
       })
       })
@@ -117,6 +117,29 @@ describe('API Client Auth Header', () => {
     expect(getAuthToken()).toBeNull();
     expect(getAuthToken()).toBeNull();
     expect(localStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
     expect(localStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
   });
   });
+
+  it('does not clear token on 401 with generic auth error', async () => {
+    server.use(
+      http.get('/api/v1/settings/spoolman', () => {
+        return HttpResponse.json(
+          { detail: 'Authentication required' },
+          { status: 401 }
+        );
+      })
+    );
+
+    setAuthToken('valid-token');
+    expect(getAuthToken()).toBe('valid-token');
+
+    try {
+      await api.getSpoolmanSettings();
+    } catch {
+      // Expected to throw
+    }
+
+    // Token should NOT be cleared for generic auth errors (might be timing issue)
+    expect(getAuthToken()).toBe('valid-token');
+  });
 });
 });
 
 
 describe('FormData requests include auth header', () => {
 describe('FormData requests include auth header', () => {

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

+ 349 - 0
frontend/src/__tests__/hooks/useFilamentMapping.test.ts

@@ -0,0 +1,349 @@
+/**
+ * Tests for the useFilamentMapping hook and helper functions.
+ *
+ * Tests the tray_info_idx matching logic that ensures the exact spool
+ * selected during slicing is used when multiple trays have identical filament.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+  buildLoadedFilaments,
+  computeAmsMapping,
+} from '../../hooks/useFilamentMapping';
+import type { PrinterStatus } from '../../api/client';
+
+// Helper to create a minimal printer status with AMS data
+function createPrinterStatus(ams: PrinterStatus['ams'], vt_tray?: PrinterStatus['vt_tray']): PrinterStatus {
+  return {
+    ams,
+    vt_tray,
+  } as PrinterStatus;
+}
+
+describe('buildLoadedFilaments', () => {
+  it('returns empty array for undefined status', () => {
+    const result = buildLoadedFilaments(undefined);
+    expect(result).toEqual([]);
+  });
+
+  it('extracts filaments from AMS units', () => {
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00' },
+          { id: 1, tray_type: 'PETG', tray_color: '00FF00', tray_info_idx: 'GFA01' },
+        ],
+      },
+    ]);
+
+    const result = buildLoadedFilaments(status);
+
+    expect(result).toHaveLength(2);
+    expect(result[0]).toMatchObject({
+      type: 'PLA',
+      color: '#FF0000',
+      amsId: 0,
+      trayId: 0,
+      globalTrayId: 0,
+      trayInfoIdx: 'GFA00',
+    });
+    expect(result[1]).toMatchObject({
+      type: 'PETG',
+      color: '#00FF00',
+      globalTrayId: 1,
+      trayInfoIdx: 'GFA01',
+    });
+  });
+
+  it('includes tray_info_idx from AMS trays', () => {
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'P4d64437' },
+        ],
+      },
+    ]);
+
+    const result = buildLoadedFilaments(status);
+
+    expect(result[0].trayInfoIdx).toBe('P4d64437');
+  });
+
+  it('handles missing tray_info_idx', () => {
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'FF0000' },  // No tray_info_idx
+        ],
+      },
+    ]);
+
+    const result = buildLoadedFilaments(status);
+
+    expect(result[0].trayInfoIdx).toBe('');
+  });
+
+  it('extracts external spool with tray_info_idx', () => {
+    const status = createPrinterStatus(
+      [],
+      { tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' }
+    );
+
+    const result = buildLoadedFilaments(status);
+
+    expect(result).toHaveLength(1);
+    expect(result[0]).toMatchObject({
+      type: 'TPU',
+      isExternal: true,
+      globalTrayId: 254,
+      trayInfoIdx: 'EXT001',
+    });
+  });
+
+  it('skips empty trays', () => {
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00' },
+          { id: 1, tray_type: '', tray_color: '' },  // Empty tray
+          { id: 2 },  // No tray_type
+        ],
+      },
+    ]);
+
+    const result = buildLoadedFilaments(status);
+
+    expect(result).toHaveLength(1);
+    expect(result[0].type).toBe('PLA');
+  });
+
+  it('marks AMS-HT units correctly', () => {
+    const status = createPrinterStatus([
+      {
+        id: 128,  // AMS-HT typically has high ID
+        tray: [
+          { id: 0, tray_type: 'PLA-CF', tray_color: '000000', tray_info_idx: 'HT001' },
+        ],  // Single tray = AMS-HT
+      },
+    ]);
+
+    const result = buildLoadedFilaments(status);
+
+    expect(result[0].isHt).toBe(true);
+    expect(result[0].globalTrayId).toBe(512);  // 128 * 4 + 0
+  });
+});
+
+describe('computeAmsMapping', () => {
+  it('returns undefined for empty filament requirements', () => {
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
+      },
+    ]);
+
+    expect(computeAmsMapping(undefined, status)).toBeUndefined();
+    expect(computeAmsMapping({ filaments: [] }, status)).toBeUndefined();
+  });
+
+  it('returns undefined when no filaments loaded', () => {
+    const reqs = {
+      filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],
+    };
+
+    expect(computeAmsMapping(reqs, undefined)).toBeUndefined();
+    expect(computeAmsMapping(reqs, createPrinterStatus([]))).toBeUndefined();
+  });
+
+  it('matches by tray_info_idx with highest priority', () => {
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, tray_info_idx: 'GFA01' },
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' },  // Same color, wrong idx
+          { id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA01' },  // Exact idx match
+          { id: 2, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA02' },  // Same color, wrong idx
+        ],
+      },
+    ]);
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([1]);  // Should pick tray 1, not tray 0
+  });
+
+  it('matches multiple identical filaments by tray_info_idx (H2D Pro scenario)', () => {
+    // This is the exact scenario from issue #245 - multiple black PLA spools
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 50, tray_info_idx: 'GFA03' },
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' },
+          { id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA01' },
+          { id: 2, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA02' },
+          { id: 3, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA03' },  // This one
+        ],
+      },
+    ]);
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([3]);  // Should pick tray 3, not tray 0
+  });
+
+  it('falls back to color match when tray_info_idx is empty', () => {
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, tray_info_idx: '' },
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: '00FF00', tray_info_idx: 'GFA00' },  // Wrong color
+          { id: 1, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA01' },  // Color match
+        ],
+      },
+    ]);
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([1]);
+  });
+
+  it('falls back to color match when tray_info_idx does not match', () => {
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, tray_info_idx: 'OLD_SPOOL' },
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'NEW_SPOOL' },  // Different idx, same color
+        ],
+      },
+    ]);
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([0]);  // Falls back to color match
+  });
+
+  it('matches by type only when color differs', () => {
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 },
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: '0000FF' },  // Same type, different color
+        ],
+      },
+    ]);
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([0]);  // Type-only match
+  });
+
+  it('returns -1 for unmatched slots', () => {
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'TPU', color: '#FF0000', used_grams: 10 },  // No TPU loaded
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'FF0000' },
+        ],
+      },
+    ]);
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([-1]);
+  });
+
+  it('avoids duplicate tray assignment', () => {
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 },
+        { slot_id: 2, type: 'PLA', color: '#FF0000', used_grams: 10 },  // Same requirements
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'FF0000' },  // Only one PLA
+        ],
+      },
+    ]);
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([0, -1]);  // First slot gets the match, second is unmatched
+  });
+
+  it('handles multi-slot mapping with tray_info_idx', () => {
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, tray_info_idx: 'GFA00' },
+        { slot_id: 2, type: 'PLA', color: '#000000', used_grams: 10, tray_info_idx: 'GFA02' },
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' },
+          { id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA01' },
+          { id: 2, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA02' },
+        ],
+      },
+    ]);
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([0, 2]);  // Each slot gets its specific tray
+  });
+
+  it('handles external spool matching', () => {
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'TPU', color: '#0000FF', used_grams: 10, tray_info_idx: 'EXT001' },
+      ],
+    };
+    const status = createPrinterStatus(
+      [],
+      { tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' }
+    );
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([254]);  // External spool global ID
+  });
+});

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

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

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

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

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

@@ -77,6 +77,20 @@ const mockFiles = [
     duplicate_count: 2,
     duplicate_count: 2,
     created_at: '2024-01-02T00:00:00Z',
     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 = {
 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();
       const user = userEvent.setup();
       render(<FileManagerPage />);
       render(<FileManagerPage />);
 
 
@@ -468,11 +501,12 @@ describe('FileManagerPage', () => {
         expect(screen.getByText('Select All')).toBeInTheDocument();
         expect(screen.getByText('Select All')).toBeInTheDocument();
       });
       });
 
 
-      // Select a sliced file (benchy.gcode.3mf)
+      // Select all files
       await user.click(screen.getByText('Select All'));
       await user.click(screen.getByText('Select All'));
 
 
       await waitFor(() => {
       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 () => {
     it('opens upload modal', async () => {
       const user = userEvent.setup();
       const user = userEvent.setup();
       render(<FileManagerPage />);
       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();
       const user = userEvent.setup();
       render(<FileManagerPage />);
       render(<FileManagerPage />);
 
 
@@ -566,24 +600,24 @@ describe('FileManagerPage', () => {
         expect(screen.getByText('Upload Files')).toBeInTheDocument();
         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
       // Get the hidden file input
       const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
       const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
       expect(fileInput).toBeInTheDocument();
       expect(fileInput).toBeInTheDocument();
 
 
       // Simulate file selection
       // 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(() => {
       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();
       const user = userEvent.setup();
       render(<FileManagerPage />);
       render(<FileManagerPage />);
 
 
@@ -597,79 +631,132 @@ describe('FileManagerPage', () => {
         expect(screen.getByText('Upload Files')).toBeInTheDocument();
         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' });
       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;
       const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      expect(fileInput).toBeInTheDocument();
+
+      // Simulate file selection
       await user.upload(fileInput, stlFile);
       await user.upload(fileInput, stlFile);
 
 
+      // STL thumbnail option should appear
       await waitFor(() => {
       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(() => {
       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(() => {
       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 />);
       render(<FileManagerPage />);
 
 
+      // Switch to list view to see the column headers
       await waitFor(() => {
       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(() => {
       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';
 const API_BASE = '/api/v1';
 
 
 // Auth token storage
 // Auth token storage
@@ -37,16 +39,27 @@ async function request<T>(
   });
   });
 
 
   if (!response.ok) {
   if (!response.ok) {
-    // Handle 401 Unauthorized - clear token and redirect to login
-    if (response.status === 401) {
-      setAuthToken(null);
-      // Don't throw here - let the auth context handle redirect
-    }
     const error = await response.json().catch(() => ({}));
     const error = await response.json().catch(() => ({}));
     const detail = error.detail;
     const detail = error.detail;
     const message = typeof detail === 'string'
     const message = typeof detail === 'string'
       ? detail
       ? detail
       : (detail ? JSON.stringify(detail) : `HTTP ${response.status}`);
       : (detail ? JSON.stringify(detail) : `HTTP ${response.status}`);
+
+    // Handle 401 Unauthorized - only clear token if it's actually invalid
+    // Don't clear on "Authentication required" which might be a timing issue
+    if (response.status === 401) {
+      const invalidTokenMessages = [
+        'Could not validate credentials',
+        'Token has expired',
+        'User not found or inactive',
+        'Invalid API key',
+        'API key has expired',
+      ];
+      if (invalidTokenMessages.some(m => message.includes(m))) {
+        setAuthToken(null);
+      }
+    }
+
     throw new Error(message);
     throw new Error(message);
   }
   }
 
 
@@ -2063,6 +2076,33 @@ export const api = {
     }>(`/printers/${printerId}/files?path=${encodeURIComponent(path)}`),
     }>(`/printers/${printerId}/files?path=${encodeURIComponent(path)}`),
   getPrinterFileDownloadUrl: (printerId: number, path: string) =>
   getPrinterFileDownloadUrl: (printerId: number, path: string) =>
     `${API_BASE}/printers/${printerId}/files/download?path=${encodeURIComponent(path)}`,
     `${API_BASE}/printers/${printerId}/files/download?path=${encodeURIComponent(path)}`,
+  getPrinterFileGcodeUrl: (printerId: number, path: string) =>
+    `${API_BASE}/printers/${printerId}/files/gcode?path=${encodeURIComponent(path)}`,
+  getPrinterFilePlates: (printerId: number, path: string) =>
+    request<{
+      printer_id: number;
+      path: string;
+      filename: string;
+      plates: Array<{
+        index: number;
+        name: string | null;
+        objects: string[];
+        has_thumbnail: boolean;
+        thumbnail_url: string | null;
+        print_time_seconds: number | null;
+        filament_used_grams: number | null;
+        filaments: Array<{
+          slot_id: number;
+          type: string;
+          color: string;
+          used_grams: number;
+          used_meters: number;
+        }>;
+      }>;
+      is_multi_plate: boolean;
+    }>(`/printers/${printerId}/files/plates?path=${encodeURIComponent(path)}`),
+  getPrinterFilePlateThumbnail: (printerId: number, plateIndex: number, path: string) =>
+    `${API_BASE}/printers/${printerId}/files/plate-thumbnail/${plateIndex}?path=${encodeURIComponent(path)}`,
   downloadPrinterFile: async (printerId: number, path: string): Promise<void> => {
   downloadPrinterFile: async (printerId: number, path: string): Promise<void> => {
     const headers: Record<string, string> = {};
     const headers: Record<string, string> = {};
     if (authToken) {
     if (authToken) {
@@ -2557,27 +2597,7 @@ export const api = {
   getArchiveForSlicer: (id: number, filename: string) =>
   getArchiveForSlicer: (id: number, filename: string) =>
     `${API_BASE}/archives/${id}/file/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
     `${API_BASE}/archives/${id}/file/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
   getArchivePlates: (archiveId: number) =>
   getArchivePlates: (archiveId: number) =>
-    request<{
-      archive_id: number;
-      filename: string;
-      plates: Array<{
-        index: number;
-        name: string | null;
-        objects: string[];
-        has_thumbnail: boolean;
-        thumbnail_url: string | null;
-        print_time_seconds: number | null;
-        filament_used_grams: number | null;
-        filaments: Array<{
-          slot_id: number;
-          type: string;
-          color: string;
-          used_grams: number;
-          used_meters: number;
-        }>;
-      }>;
-      is_multi_plate: boolean;
-    }>(`/archives/${archiveId}/plates`),
+    request<ArchivePlatesResponse>(`/archives/${archiveId}/plates`),
   getArchiveFilamentRequirements: (archiveId: number, plateId?: number) =>
   getArchiveFilamentRequirements: (archiveId: number, plateId?: number) =>
     request<{
     request<{
       archive_id: number;
       archive_id: number;
@@ -2713,6 +2733,8 @@ export const api = {
   },
   },
   checkFfmpeg: () =>
   checkFfmpeg: () =>
     request<{ installed: boolean; path: string | null }>('/settings/check-ffmpeg'),
     request<{ installed: boolean; path: string | null }>('/settings/check-ffmpeg'),
+  getNetworkInterfaces: () =>
+    request<{ interfaces: NetworkInterface[] }>('/settings/network-interfaces'),
 
 
   // Cloud
   // Cloud
   getCloudStatus: () => request<CloudAuthStatus>('/cloud/status'),
   getCloudStatus: () => request<CloudAuthStatus>('/cloud/status'),
@@ -2794,13 +2816,11 @@ export const api = {
 
 
   // Tasmota Discovery (auto-detects network)
   // Tasmota Discovery (auto-detects network)
   startTasmotaScan: () =>
   startTasmotaScan: () =>
-    fetch(`${API_BASE}/smart-plugs/discover/scan`, { method: 'POST' })
-      .then(res => res.ok ? res.json() : res.json().then(e => { throw new Error(e.detail || `HTTP ${res.status}`); })),
+    request<TasmotaScanStatus>('/smart-plugs/discover/scan', { method: 'POST' }),
   getTasmotaScanStatus: () =>
   getTasmotaScanStatus: () =>
     request<TasmotaScanStatus>('/smart-plugs/discover/status'),
     request<TasmotaScanStatus>('/smart-plugs/discover/status'),
   stopTasmotaScan: () =>
   stopTasmotaScan: () =>
-    fetch(`${API_BASE}/smart-plugs/discover/stop`, { method: 'POST' })
-      .then(res => res.ok ? res.json() : res.json().then(e => { throw new Error(e.detail || `HTTP ${res.status}`); })),
+    request<TasmotaScanStatus>('/smart-plugs/discover/stop', { method: 'POST' }),
   getDiscoveredTasmotaDevices: () =>
   getDiscoveredTasmotaDevices: () =>
     request<DiscoveredTasmotaDevice[]>('/smart-plugs/discover/devices'),
     request<DiscoveredTasmotaDevice[]>('/smart-plugs/discover/devices'),
 
 
@@ -3565,27 +3585,7 @@ export const api = {
       }
       }
     ),
     ),
   getLibraryFilePlates: (fileId: number) =>
   getLibraryFilePlates: (fileId: number) =>
-    request<{
-      file_id: number;
-      filename: string;
-      plates: Array<{
-        index: number;
-        name: string | null;
-        objects: string[];
-        has_thumbnail: boolean;
-        thumbnail_url: string | null;
-        print_time_seconds: number | null;
-        filament_used_grams: number | null;
-        filaments: Array<{
-          slot_id: number;
-          type: string;
-          color: string;
-          used_grams: number;
-          used_meters: number;
-        }>;
-      }>;
-      is_multi_plate: boolean;
-    }>(`/library/files/${fileId}/plates`),
+    request<LibraryFilePlatesResponse>(`/library/files/${fileId}/plates`),
   getLibraryFileFilamentRequirements: (fileId: number, plateId?: number) =>
   getLibraryFileFilamentRequirements: (fileId: number, plateId?: number) =>
     request<{
     request<{
       file_id: number;
       file_id: number;
@@ -3802,6 +3802,11 @@ export interface LibraryFile {
   created_by_username: string | null;
   created_by_username: string | null;
   created_at: string;
   created_at: string;
   updated_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 {
 export interface LibraryFileListItem {
@@ -3820,6 +3825,7 @@ export interface LibraryFileListItem {
   print_name: string | null;
   print_name: string | null;
   print_time_seconds: number | null;
   print_time_seconds: number | null;
   filament_used_grams: number | null;
   filament_used_grams: number | null;
+  sliced_for_model: string | null;
 }
 }
 
 
 export interface LibraryFileUpdate {
 export interface LibraryFileUpdate {
@@ -3986,9 +3992,17 @@ export interface VirtualPrinterSettings {
   mode: VirtualPrinterMode;
   mode: VirtualPrinterMode;
   model: string;
   model: string;
   target_printer_id: number | null;  // For proxy mode
   target_printer_id: number | null;  // For proxy mode
+  remote_interface_ip: string | null;  // For SSDP proxy across networks
   status: VirtualPrinterStatus;
   status: VirtualPrinterStatus;
 }
 }
 
 
+export interface NetworkInterface {
+  name: string;
+  ip: string;
+  netmask: string;
+  subnet: string;
+}
+
 export interface VirtualPrinterModels {
 export interface VirtualPrinterModels {
   models: Record<string, string>;  // SSDP code -> display name
   models: Record<string, string>;  // SSDP code -> display name
   default: string;
   default: string;
@@ -4018,6 +4032,7 @@ export const virtualPrinterApi = {
     mode?: 'immediate' | 'review' | 'print_queue' | 'proxy';
     mode?: 'immediate' | 'review' | 'print_queue' | 'proxy';
     model?: string;
     model?: string;
     target_printer_id?: number;
     target_printer_id?: number;
+    remote_interface_ip?: string;
   }) => {
   }) => {
     const params = new URLSearchParams();
     const params = new URLSearchParams();
     if (data.enabled !== undefined) params.set('enabled', String(data.enabled));
     if (data.enabled !== undefined) params.set('enabled', String(data.enabled));
@@ -4025,6 +4040,7 @@ export const virtualPrinterApi = {
     if (data.mode !== undefined) params.set('mode', data.mode);
     if (data.mode !== undefined) params.set('mode', data.mode);
     if (data.model !== undefined) params.set('model', data.model);
     if (data.model !== undefined) params.set('model', data.model);
     if (data.target_printer_id !== undefined) params.set('target_printer_id', String(data.target_printer_id));
     if (data.target_printer_id !== undefined) params.set('target_printer_id', String(data.target_printer_id));
+    if (data.remote_interface_ip !== undefined) params.set('remote_interface_ip', data.remote_interface_ip);
 
 
     return request<VirtualPrinterSettings>(`/settings/virtual-printer?${params.toString()}`, {
     return request<VirtualPrinterSettings>(`/settings/virtual-printer?${params.toString()}`, {
       method: 'PUT',
       method: 'PUT',

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

@@ -87,10 +87,12 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
     if (isVisible && triggerRef.current && cardRef.current) {
     if (isVisible && triggerRef.current && cardRef.current) {
       const triggerRect = triggerRef.current.getBoundingClientRect();
       const triggerRect = triggerRef.current.getBoundingClientRect();
       const cardHeight = cardRef.current.offsetHeight;
       const cardHeight = cardRef.current.offsetHeight;
-      const spaceAbove = triggerRect.top;
+      // Account for fixed header (56px) - space above should exclude header area
+      const headerHeight = 56;
+      const spaceAbove = triggerRect.top - headerHeight;
       const spaceBelow = window.innerHeight - triggerRect.bottom;
       const spaceBelow = window.innerHeight - triggerRect.bottom;
 
 
-      // Prefer top, but flip to bottom if not enough space
+      // Prefer top, but flip to bottom if not enough space (accounting for header)
       if (spaceAbove < cardHeight + 12 && spaceBelow > spaceAbove) {
       if (spaceAbove < cardHeight + 12 && spaceBelow > spaceAbove) {
         setPosition('bottom');
         setPosition('bottom');
       } else {
       } else {

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

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

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

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

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

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

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

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

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

@@ -1,10 +1,13 @@
 import { useEffect, useRef, useState } from 'react';
 import { useEffect, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
 import * as THREE from 'three';
 import * as THREE from 'three';
 import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
 import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
 import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
 import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
+import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
 import JSZip from 'jszip';
 import JSZip from 'jszip';
 import { Loader2, RotateCcw, ZoomIn, ZoomOut } from 'lucide-react';
 import { Loader2, RotateCcw, ZoomIn, ZoomOut } from 'lucide-react';
 import { Button } from './Button';
 import { Button } from './Button';
+import { getAuthToken } from '../api/client';
 
 
 interface BuildVolume {
 interface BuildVolume {
   x: number;
   x: number;
@@ -14,8 +17,10 @@ interface BuildVolume {
 
 
 interface ModelViewerProps {
 interface ModelViewerProps {
   url: string;
   url: string;
+  fileType?: string;
   buildVolume?: BuildVolume;
   buildVolume?: BuildVolume;
   filamentColors?: string[];
   filamentColors?: string[];
+  selectedPlateId?: number | null;
   className?: string;
   className?: string;
 }
 }
 
 
@@ -29,12 +34,21 @@ interface ObjectData {
   id: string;
   id: string;
   meshes: MeshData[];
   meshes: MeshData[];
   defaultExtruder: number; // Default extruder for object (used if mesh doesn't have specific one)
   defaultExtruder: number; // Default extruder for object (used if mesh doesn't have specific one)
+  plateId?: number | null;
 }
 }
 
 
 interface BuildItem {
 interface BuildItem {
   objectId: string;
   objectId: string;
   transform: THREE.Matrix4;
   transform: THREE.Matrix4;
   extruder?: number; // Can override object's extruder
   extruder?: number; // Can override object's extruder
+  plateId?: number | null;
+}
+
+interface Parsed3MFData {
+  objects: Map<string, ObjectData>;
+  buildItems: BuildItem[];
+  plateBounds: Map<number, { minX: number; minY: number; maxX: number; maxY: number }>;
+  plateOffsets: Map<number, { offsetX: number; offsetY: number }>;
 }
 }
 
 
 // Parse 3MF transform - keep in 3MF coordinate space (Z-up)
 // 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;
   return meshes;
 }
 }
 
 
-async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string, ObjectData>; buildItems: BuildItem[] }> {
-  const zip = await JSZip.loadAsync(arrayBuffer);
+function parsePlateIdFromAttributes(element: Element): number | null {
+  const plateAttribute = Array.from(element.attributes).find((attr) => {
+    const name = attr.name.toLowerCase();
+    return (
+      name === 'plate_id' ||
+      name === 'plater_id' ||
+      name === 'plateid' ||
+      name === 'platerid' ||
+      name.endsWith(':plate_id') ||
+      name.endsWith(':plater_id')
+    );
+  });
+
+  if (!plateAttribute?.value) return null;
+  const parsed = Number.parseInt(plateAttribute.value, 10);
+  return Number.isFinite(parsed) ? parsed : null;
+}
+
+async function parse3MF(arrayBuffer: ArrayBuffer): Promise<Parsed3MFData> {
+  let zip: JSZip;
+  try {
+    zip = await JSZip.loadAsync(arrayBuffer);
+  } catch {
+    throw new Error('Unsupported file format');
+  }
   const objects = new Map<string, ObjectData>();
   const objects = new Map<string, ObjectData>();
   const buildItems: BuildItem[] = [];
   const buildItems: BuildItem[] = [];
+  const plateBounds = new Map<number, { minX: number; minY: number; maxX: number; maxY: number }>();
+  const plateOffsets = new Map<number, { offsetX: number; offsetY: number }>();
   const parser = new DOMParser();
   const parser = new DOMParser();
 
 
   // Helper to load and parse a model file from the zip
   // Helper to load and parse a model file from the zip
@@ -121,6 +160,8 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
   // Maps: object ID -> default extruder, and (object ID, part ID) -> part-specific extruder
   // Maps: object ID -> default extruder, and (object ID, part ID) -> part-specific extruder
   const extruderMapById = new Map<string, number>();
   const extruderMapById = new Map<string, number>();
   const partExtruderMap = new Map<string, number>(); // Key: "objectId:partId"
   const partExtruderMap = new Map<string, number>(); // Key: "objectId:partId"
+  const objectNameById = new Map<string, string>();
+  const plateAssignmentsByObjectId = new Map<string, number>();
   const modelSettingsFile = zip.files['Metadata/model_settings.config'];
   const modelSettingsFile = zip.files['Metadata/model_settings.config'];
   if (modelSettingsFile) {
   if (modelSettingsFile) {
     try {
     try {
@@ -132,7 +173,7 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
         const objectId = objEl.getAttribute('id');
         const objectId = objEl.getAttribute('id');
         if (!objectId) continue;
         if (!objectId) continue;
 
 
-        // Find object-level extruder
+        // Find object-level extruder + name
         const directMetadata = Array.from(objEl.children).filter(
         const directMetadata = Array.from(objEl.children).filter(
           (el) => el.tagName === 'metadata' && el.getAttribute('key') === 'extruder'
           (el) => el.tagName === 'metadata' && el.getAttribute('key') === 'extruder'
         );
         );
@@ -143,6 +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
         // Find part-level extruders
         const partElements = objEl.getElementsByTagName('part');
         const partElements = objEl.getElementsByTagName('part');
         for (let j = 0; j < partElements.length; j++) {
         for (let j = 0; j < partElements.length; j++) {
@@ -162,11 +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 {
     } catch {
       // Silently ignore model_settings.config parsing errors
       // Silently ignore model_settings.config parsing errors
     }
     }
   }
   }
 
 
+  // Parse plate_*.json for plate assignments by object name (source-only / unsliced files)
+  const plateAssignmentsByName = new Map<string, number>();
+  const plateJsonNames = Object.keys(zip.files).filter(
+    (name) => name.startsWith('Metadata/plate_') && name.endsWith('.json')
+  );
+  for (const name of plateJsonNames) {
+    const match = name.match(/^Metadata\/plate_(\d+)\.json$/);
+    if (!match) continue;
+    const plateIndex = Number.parseInt(match[1], 10);
+    if (!Number.isFinite(plateIndex)) continue;
+    try {
+      const payload = await zip.files[name].async('string');
+      const json = JSON.parse(payload) as { bbox_objects?: Array<{ name?: string }>; bbox_all?: number[] };
+      const objectsList = json.bbox_objects ?? [];
+      for (const entry of objectsList) {
+        if (entry?.name) {
+          plateAssignmentsByName.set(entry.name, plateIndex);
+        }
+      }
+      if (Array.isArray(json.bbox_all) && json.bbox_all.length >= 4) {
+        const [minX, minY, maxX, maxY] = json.bbox_all;
+        if ([minX, minY, maxX, maxY].every((value) => Number.isFinite(value))) {
+          plateBounds.set(plateIndex, { minX, minY, maxX, maxY });
+        }
+      }
+    } catch {
+      // Ignore plate json parsing errors
+    }
+  }
+
   // Find the main 3D model file
   // Find the main 3D model file
   const mainModelPath = Object.keys(zip.files).find(
   const mainModelPath = Object.keys(zip.files).find(
     (name) => name === '3D/3dmodel.model' || name.endsWith('/3dmodel.model')
     (name) => name === '3D/3dmodel.model' || name.endsWith('/3dmodel.model')
@@ -184,11 +317,11 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
         }
         }
       }
       }
     }
     }
-    return { objects, buildItems };
+    return { objects, buildItems, plateBounds, plateOffsets };
   }
   }
 
 
   const mainDoc = await loadModelFile(mainModelPath);
   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
   // Parse objects - Bambu Studio uses components to reference external files
   const objectElements = mainDoc.getElementsByTagName('object');
   const objectElements = mainDoc.getElementsByTagName('object');
@@ -197,6 +330,8 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
     const objectId = objEl.getAttribute('id');
     const objectId = objEl.getAttribute('id');
     if (!objectId) continue;
     if (!objectId) continue;
 
 
+    const objectPlateId = parsePlateIdFromAttributes(objEl) ?? plateAssignmentsByObjectId.get(objectId) ?? null;
+
     // Get default extruder from model_settings.config map, falling back to attribute or default
     // Get default extruder from model_settings.config map, falling back to attribute or default
     let defaultExtruder = extruderMapById.get(objectId) ?? -1;
     let defaultExtruder = extruderMapById.get(objectId) ?? -1;
     if (defaultExtruder < 0) {
     if (defaultExtruder < 0) {
@@ -279,7 +414,7 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
     }
     }
 
 
     if (meshes.length > 0) {
     if (meshes.length > 0) {
-      objects.set(objectId, { id: objectId, meshes, defaultExtruder });
+      objects.set(objectId, { id: objectId, meshes, defaultExtruder, plateId: objectPlateId });
     }
     }
   }
   }
 
 
@@ -293,11 +428,15 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
       if (!objectId) continue;
       if (!objectId) continue;
 
 
       const transform = parseTransform(itemEl.getAttribute('transform'));
       const transform = parseTransform(itemEl.getAttribute('transform'));
-      buildItems.push({ objectId, transform });
+      const itemPlateId = parsePlateIdFromAttributes(itemEl);
+      const objectPlateId = objects.get(objectId)?.plateId ?? null;
+      const objectName = objectNameById.get(objectId);
+      const namePlateId = objectName ? plateAssignmentsByName.get(objectName) ?? null : null;
+      buildItems.push({ objectId, transform, plateId: itemPlateId ?? objectPlateId ?? namePlateId ?? null });
     }
     }
   }
   }
 
 
-  return { objects, buildItems };
+  return { objects, buildItems, plateBounds, plateOffsets };
 }
 }
 
 
 function createGeometryFromMesh(mesh: MeshData): THREE.BufferGeometry {
 function createGeometryFromMesh(mesh: MeshData): THREE.BufferGeometry {
@@ -321,14 +460,146 @@ function createGeometryFromMesh(mesh: MeshData): THREE.BufferGeometry {
   return geometry;
   return geometry;
 }
 }
 
 
-export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, filamentColors, className = '' }: ModelViewerProps) {
+function disposeGroup(group: THREE.Group) {
+  group.traverse((child) => {
+    if (child instanceof THREE.Mesh) {
+      child.geometry.dispose();
+      if (Array.isArray(child.material)) {
+        for (const material of child.material) {
+          material.dispose();
+        }
+      } else {
+        child.material.dispose();
+      }
+    }
+  });
+}
+
+function buildModelGroup(
+  parsedData: Parsed3MFData,
+  selectedPlateId: number | null,
+  filamentColors?: string[],
+): THREE.Group {
+  const { objects, buildItems } = parsedData;
+  const group = new THREE.Group();
+
+  // Create materials for each extruder color
+  const getMaterial = (extruder: number): THREE.MeshPhongMaterial => {
+    const defaultColor = '#00ae42';
+    const colorStr = filamentColors?.[extruder] || defaultColor;
+    // Convert hex color string to THREE.js color
+    const color = new THREE.Color(colorStr);
+    return new THREE.MeshPhongMaterial({
+      color,
+      shininess: 30,
+      flatShading: false,
+    });
+  };
+
+  // Group geometries by extruder index (using per-mesh extruder)
+  const geometriesByExtruder = new Map<number, THREE.BufferGeometry[]>();
+
+  const hasPlateAssignments = buildItems.some((item) => item.plateId != null);
+  const plateFilteredItems = selectedPlateId == null || !hasPlateAssignments
+    ? buildItems
+    : buildItems.filter((item) => item.plateId === selectedPlateId);
+  const activeBuildItems = plateFilteredItems.length > 0 ? plateFilteredItems : buildItems;
+
+  // If we have build items, use them for positioning
+  if (activeBuildItems.length > 0) {
+    for (const item of activeBuildItems) {
+      const objectData = objects.get(item.objectId);
+      if (!objectData) continue;
+
+      for (const meshData of objectData.meshes) {
+        // Use mesh's extruder, or item override, or object default
+        const extruder = item.extruder ?? meshData.extruder;
+
+        // Apply build transform to vertices in 3MF space BEFORE coordinate conversion
+        const transformedVertices: number[] = [];
+        for (let k = 0; k < meshData.vertices.length; k += 3) {
+          const v = new THREE.Vector3(
+            meshData.vertices[k],
+            meshData.vertices[k + 1],
+            meshData.vertices[k + 2]
+          );
+          v.applyMatrix4(item.transform);
+          transformedVertices.push(v.x, v.y, v.z);
+        }
+        // Now create geometry with coordinate conversion
+        const geometry = createGeometryFromMesh({
+          vertices: transformedVertices,
+          triangles: meshData.triangles,
+          extruder: extruder,
+        });
+
+        if (!geometriesByExtruder.has(extruder)) {
+          geometriesByExtruder.set(extruder, []);
+        }
+        geometriesByExtruder.get(extruder)!.push(geometry);
+      }
+    }
+  } else {
+    // Fallback: just add all objects without transforms
+    for (const objectData of objects.values()) {
+      for (const meshData of objectData.meshes) {
+        // Use per-mesh extruder
+        const extruder = meshData.extruder;
+        const geometry = createGeometryFromMesh(meshData);
+        if (!geometriesByExtruder.has(extruder)) {
+          geometriesByExtruder.set(extruder, []);
+        }
+        geometriesByExtruder.get(extruder)!.push(geometry);
+      }
+    }
+  }
+
+  // Create meshes for each extruder group
+  for (const [extruder, geometries] of geometriesByExtruder) {
+    if (geometries.length === 0) continue;
+
+    const mergedGeometry = geometries.length === 1
+      ? geometries[0]
+      : mergeGeometries(geometries, false);
+
+    if (mergedGeometry) {
+      const material = getMaterial(extruder);
+      const mesh = new THREE.Mesh(mergedGeometry, material);
+      group.add(mesh);
+    }
+
+    // Dispose individual geometries if merged
+    if (geometries.length > 1) {
+      for (const geom of geometries) {
+        geom.dispose();
+      }
+    }
+  }
+
+  return group;
+}
+
+export function ModelViewer({
+  url,
+  fileType,
+  buildVolume = { x: 256, y: 256, z: 256 },
+  filamentColors,
+  selectedPlateId = null,
+  className = '',
+}: ModelViewerProps) {
+  const { t } = useTranslation();
   const containerRef = useRef<HTMLDivElement>(null);
   const containerRef = useRef<HTMLDivElement>(null);
   const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
   const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
   const sceneRef = useRef<THREE.Scene | null>(null);
   const sceneRef = useRef<THREE.Scene | null>(null);
   const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
   const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
   const controlsRef = useRef<OrbitControls | null>(null);
   const controlsRef = useRef<OrbitControls | null>(null);
+  const modelGroupRef = useRef<THREE.Group | null>(null);
+  const plateRef = useRef<THREE.Mesh | null>(null);
+  const gridRef = useRef<THREE.GridHelper | null>(null);
   const [loading, setLoading] = useState(true);
   const [loading, setLoading] = useState(true);
   const [error, setError] = useState<string | null>(null);
   const [error, setError] = useState<string | null>(null);
+  const [parsedData, setParsedData] = useState<Parsed3MFData | null>(null);
+  const [stlGeometry, setStlGeometry] = useState<THREE.BufferGeometry | null>(null);
 
 
   useEffect(() => {
   useEffect(() => {
     if (!containerRef.current) return;
     if (!containerRef.current) return;
@@ -377,6 +648,7 @@ export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, fil
     const gridDivisions = Math.ceil(gridSize / 16);
     const gridDivisions = Math.ceil(gridSize / 16);
     const gridHelper = new THREE.GridHelper(gridSize, gridDivisions, 0x444444, 0x333333);
     const gridHelper = new THREE.GridHelper(gridSize, gridDivisions, 0x444444, 0x333333);
     scene.add(gridHelper);
     scene.add(gridHelper);
+    gridRef.current = gridHelper;
 
 
     // Build plate indicator
     // Build plate indicator
     const plateGeometry = new THREE.PlaneGeometry(buildVolume.x, buildVolume.y);
     const plateGeometry = new THREE.PlaneGeometry(buildVolume.x, buildVolume.y);
@@ -390,6 +662,7 @@ export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, fil
     plate.rotation.x = -Math.PI / 2;
     plate.rotation.x = -Math.PI / 2;
     plate.position.y = -0.5; // Slightly below Y=0 so models sit on top
     plate.position.y = -0.5; // Slightly below Y=0 so models sit on top
     scene.add(plate);
     scene.add(plate);
+    plateRef.current = plate;
 
 
     // Animation loop - keep it simple for reliability
     // Animation loop - keep it simple for reliability
     let animationId: number;
     let animationId: number;
@@ -400,163 +673,183 @@ export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, fil
     };
     };
     animate();
     animate();
 
 
-    // Load 3MF
-    fetch(url)
-      .then((res) => {
-        if (!res.ok) throw new Error('Failed to load file');
-        return res.arrayBuffer();
-      })
-      .then(parse3MF)
-      .then(({ objects, buildItems }) => {
-        if (objects.size === 0) {
-          throw new Error('No meshes found in 3MF file');
-        }
+    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 = () => {
     const handleResize = () => {
       if (!container) return;
       if (!container) return;
       const w = container.clientWidth;
       const w = container.clientWidth;
       const h = container.clientHeight;
       const h = container.clientHeight;
+      if (w === 0 || h === 0) return;
       camera.aspect = w / h;
       camera.aspect = w / h;
       camera.updateProjectionMatrix();
       camera.updateProjectionMatrix();
       renderer.setSize(w, h);
       renderer.setSize(w, h);
     };
     };
     window.addEventListener('resize', handleResize);
     window.addEventListener('resize', handleResize);
+    const resizeObserver = new ResizeObserver(() => {
+      handleResize();
+    });
+    resizeObserver.observe(container);
 
 
     return () => {
     return () => {
       window.removeEventListener('resize', handleResize);
       window.removeEventListener('resize', handleResize);
+      resizeObserver.disconnect();
       cancelAnimationFrame(animationId);
       cancelAnimationFrame(animationId);
       controls.dispose();
       controls.dispose();
       renderer.dispose();
       renderer.dispose();
       container.removeChild(renderer.domElement);
       container.removeChild(renderer.domElement);
+      modelGroupRef.current = null;
+      plateRef.current = null;
+      gridRef.current = null;
     };
     };
-  }, [url, buildVolume, filamentColors]);
+  }, [url, buildVolume, fileType, 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 = () => {
   const resetView = () => {
     if (cameraRef.current && controlsRef.current) {
     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 { ModelViewer } from './ModelViewer';
 import { GcodeViewer } from './GcodeViewer';
 import { GcodeViewer } from './GcodeViewer';
 import { Button } from './Button';
 import { Button } from './Button';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { openInSlicer } from '../utils/slicer';
 import { openInSlicer } from '../utils/slicer';
+import type { ArchivePlatesResponse, LibraryFilePlatesResponse, PlateMetadata } from '../types/plates';
 
 
 type ViewTab = '3d' | 'gcode';
 type ViewTab = '3d' | 'gcode';
 
 
 interface ModelViewerModalProps {
 interface ModelViewerModalProps {
-  archiveId: number;
+  archiveId?: number;
+  libraryFileId?: number;
   title: string;
   title: string;
+  fileType?: string;
   onClose: () => void;
   onClose: () => void;
 }
 }
 
 
@@ -22,10 +26,26 @@ interface Capabilities {
   filament_colors: string[];
   filament_colors: string[];
 }
 }
 
 
-export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModalProps) {
+export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, onClose }: ModelViewerModalProps) {
+  const { t } = useTranslation();
+  const isLibrary = libraryFileId != null;
   const [activeTab, setActiveTab] = useState<ViewTab | null>(null);
   const [activeTab, setActiveTab] = useState<ViewTab | null>(null);
   const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
   const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
   const [loading, setLoading] = useState(true);
   const [loading, setLoading] = useState(true);
+  const [platesData, setPlatesData] = useState<ArchivePlatesResponse | LibraryFilePlatesResponse | null>(null);
+  const [platesLoading, setPlatesLoading] = useState(false);
+  const [selectedPlateId, setSelectedPlateId] = useState<number | null>(null);
+  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
   // Close on Escape key
   useEffect(() => {
   useEffect(() => {
@@ -37,6 +57,31 @@ export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModal
   }, [onClose]);
   }, [onClose]);
 
 
   useEffect(() => {
   useEffect(() => {
+    setLoading(true);
+
+    if (isLibrary) {
+      const normalizedType = (fileType || '').toLowerCase();
+      const hasModel = normalizedType === '3mf' || normalizedType === 'stl';
+      const hasGcode = normalizedType === 'gcode' || normalizedType === '3mf';
+      setCapabilities({
+        has_model: hasModel,
+        has_gcode: hasGcode,
+        has_source: false,
+        build_volume: { x: 256, y: 256, z: 256 },
+        filament_colors: [],
+      });
+      setActiveTab(hasModel ? '3d' : hasGcode ? 'gcode' : null);
+      setLoading(false);
+      return;
+    }
+
+    if (!archiveId) {
+      setCapabilities(null);
+      setActiveTab(null);
+      setLoading(false);
+      return;
+    }
+
     api.getArchiveCapabilities(archiveId)
     api.getArchiveCapabilities(archiveId)
       .then(caps => {
       .then(caps => {
         setCapabilities(caps);
         setCapabilities(caps);
@@ -54,31 +99,213 @@ export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModal
         setActiveTab('3d');
         setActiveTab('3d');
         setLoading(false);
         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 = () => {
   const handleOpenInSlicer = () => {
+    if (!canOpenInSlicer) return;
     // URL must include .3mf filename for Bambu Studio to recognize the format
     // URL must include .3mf filename for Bambu Studio to recognize the format
     const filename = title || 'model';
     const filename = title || 'model';
-    const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId, filename)}`;
+    if (isLibrary) {
+      const downloadUrl = `${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId!)}`;
+      openInSlicer(downloadUrl);
+      return;
+    }
+    const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId!, filename)}`;
     openInSlicer(downloadUrl);
     openInSlicer(downloadUrl);
   };
   };
 
 
   return (
   return (
     <div
     <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}
       onClick={onClose}
     >
     >
       <div
       <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()}
         onClick={(e) => e.stopPropagation()}
       >
       >
         {/* Header */}
         {/* Header */}
         <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
         <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
-          <h2 className="text-lg font-semibold text-white truncate flex-1 mr-4">{title}</h2>
+          <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">
           <div className="flex items-center gap-2">
-            <Button variant="secondary" size="sm" onClick={handleOpenInSlicer}>
+            <Button variant="secondary" size="sm" onClick={handleOpenInSlicer} disabled={!canOpenInSlicer}>
               <ExternalLink className="w-4 h-4" />
               <ExternalLink className="w-4 h-4" />
-              Open in Slicer
+              {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>
             <Button variant="ghost" size="sm" onClick={onClose}>
             <Button variant="ghost" size="sm" onClick={onClose}>
               <X className="w-5 h-5" />
               <X className="w-5 h-5" />
@@ -101,8 +328,8 @@ export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModal
               }`}
               }`}
             >
             >
               <Box className="w-4 h-4" />
               <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>
             <button
             <button
               onClick={() => capabilities.has_gcode && setActiveTab('gcode')}
               onClick={() => capabilities.has_gcode && setActiveTab('gcode')}
@@ -116,8 +343,8 @@ export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModal
               }`}
               }`}
             >
             >
               <Code2 className="w-4 h-4" />
               <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>
             </button>
           </div>
           </div>
         )}
         )}
@@ -129,23 +356,246 @@ export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModal
               <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
               <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
             </div>
             </div>
           ) : activeTab === '3d' && capabilities ? (
           ) : activeTab === '3d' && capabilities ? (
-            <ModelViewer
-              url={capabilities.has_source
-                ? api.getSource3mfDownloadUrl(archiveId)
-                : api.getArchiveDownload(archiveId)}
-              buildVolume={capabilities.build_volume}
-              filamentColors={capabilities.filament_colors}
-              className="w-full h-full"
-            />
+            <div
+              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 ? (
           ) : activeTab === 'gcode' && capabilities ? (
             <GcodeViewer
             <GcodeViewer
-              gcodeUrl={api.getArchiveGcode(archiveId)}
+              gcodeUrl={isLibrary ? api.getLibraryFileGcodeUrl(libraryFileId!) : api.getArchiveGcode(archiveId!)}
               filamentColors={capabilities.filament_colors}
               filamentColors={capabilities.filament_colors}
               className="w-full h-full"
               className="w-full h-full"
             />
             />
           ) : (
           ) : (
             <div className="w-full h-full flex items-center justify-center text-bambu-gray">
             <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>
           )}
           )}
         </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 { useFilamentMapping } from '../../hooks/useFilamentMapping';
 import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
 import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
 import { isPlaceholderDate } from '../../utils/amsHelpers';
 import { isPlaceholderDate } from '../../utils/amsHelpers';
+import { toDateTimeLocalValue } from '../../utils/date';
 import { PrinterSelector } from './PrinterSelector';
 import { PrinterSelector } from './PrinterSelector';
 import { PlateSelector } from './PlateSelector';
 import { PlateSelector } from './PlateSelector';
 import { FilamentMapping } from './FilamentMapping';
 import { FilamentMapping } from './FilamentMapping';
@@ -90,7 +91,8 @@ export function PrintModal({
       let scheduledTime = '';
       let scheduledTime = '';
       if (queueItem.scheduled_time && !isPlaceholderDate(queueItem.scheduled_time)) {
       if (queueItem.scheduled_time && !isPlaceholderDate(queueItem.scheduled_time)) {
         const date = new Date(queueItem.scheduled_time);
         const date = new Date(queueItem.scheduled_time);
-        scheduledTime = date.toISOString().slice(0, 16);
+        // Use toDateTimeLocalValue to convert UTC to local time for datetime-local input
+        scheduledTime = toDateTimeLocalValue(date);
       }
       }
 
 
       return {
       return {
@@ -180,8 +182,15 @@ export function PrintModal({
     enabled: !!archiveId && !isLibraryFile,
     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
   // 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
   // Fetch plates for archives
   const { data: archivePlatesData, isError: archivePlatesError } = useQuery({
   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 [localMode, setLocalMode] = useState<LocalMode>('immediate');
   const [localModel, setLocalModel] = useState('3DPrinter-X1-Carbon');
   const [localModel, setLocalModel] = useState('3DPrinter-X1-Carbon');
   const [localTargetPrinterId, setLocalTargetPrinterId] = useState<number | null>(null);
   const [localTargetPrinterId, setLocalTargetPrinterId] = useState<number | null>(null);
+  const [localRemoteInterfaceIp, setLocalRemoteInterfaceIp] = useState('');
   const [showAccessCode, setShowAccessCode] = useState(false);
   const [showAccessCode, setShowAccessCode] = useState(false);
-  const [pendingAction, setPendingAction] = useState<'toggle' | 'accessCode' | 'mode' | 'model' | 'targetPrinter' | null>(null);
+  const [pendingAction, setPendingAction] = useState<'toggle' | 'accessCode' | 'mode' | 'model' | 'targetPrinter' | 'remoteInterface' | null>(null);
 
 
   // Fetch current settings
   // Fetch current settings
   const { data: settings, isLoading } = useQuery({
   const { data: settings, isLoading } = useQuery({
@@ -41,6 +42,13 @@ export function VirtualPrinterSettings() {
     queryFn: api.getPrinters,
     queryFn: api.getPrinters,
   });
   });
 
 
+  // Fetch network interfaces for SSDP proxy (only in proxy mode)
+  const { data: networkInterfaces } = useQuery({
+    queryKey: ['network-interfaces'],
+    queryFn: () => api.getNetworkInterfaces().then(res => res.interfaces),
+    enabled: localMode === 'proxy',
+  });
+
   // Initialize local state from settings
   // Initialize local state from settings
   useEffect(() => {
   useEffect(() => {
     if (settings) {
     if (settings) {
@@ -53,12 +61,13 @@ export function VirtualPrinterSettings() {
       setLocalMode(mode);
       setLocalMode(mode);
       setLocalModel(settings.model);
       setLocalModel(settings.model);
       setLocalTargetPrinterId(settings.target_printer_id);
       setLocalTargetPrinterId(settings.target_printer_id);
+      setLocalRemoteInterfaceIp(settings.remote_interface_ip || '');
     }
     }
   }, [settings]);
   }, [settings]);
 
 
   // Update mutation
   // Update mutation
   const updateMutation = useMutation({
   const updateMutation = useMutation({
-    mutationFn: (data: { enabled?: boolean; access_code?: string; mode?: LocalMode; model?: string; target_printer_id?: number }) =>
+    mutationFn: (data: { enabled?: boolean; access_code?: string; mode?: LocalMode; model?: string; target_printer_id?: number; remote_interface_ip?: string }) =>
       virtualPrinterApi.updateSettings(data),
       virtualPrinterApi.updateSettings(data),
     onSuccess: () => {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['virtual-printer-settings'] });
       queryClient.invalidateQueries({ queryKey: ['virtual-printer-settings'] });
@@ -148,6 +157,12 @@ export function VirtualPrinterSettings() {
     updateMutation.mutate({ model });
     updateMutation.mutate({ model });
   };
   };
 
 
+  const handleRemoteInterfaceChange = (ip: string) => {
+    setLocalRemoteInterfaceIp(ip);
+    setPendingAction('remoteInterface');
+    updateMutation.mutate({ remote_interface_ip: ip });
+  };
+
   if (isLoading) {
   if (isLoading) {
     return (
     return (
       <Card>
       <Card>
@@ -350,6 +365,45 @@ export function VirtualPrinterSettings() {
             </div>
             </div>
           )}
           )}
 
 
+          {/* Remote Interface - only for proxy mode (SSDP proxy) */}
+          {localMode === 'proxy' && (
+            <div className="py-3 border-t border-bambu-dark-tertiary">
+              <div className="text-white font-medium mb-2">{t('virtualPrinter.remoteInterface.title')}</div>
+              <div className="text-sm text-bambu-gray mb-3">
+                {localRemoteInterfaceIp ? (
+                  <span className="flex items-center gap-1 text-green-400">
+                    <Check className="w-4 h-4" />
+                    {t('virtualPrinter.remoteInterface.configured')}
+                  </span>
+                ) : (
+                  <span className="flex items-center gap-1 text-bambu-gray">
+                    <Info className="w-4 h-4" />
+                    {t('virtualPrinter.remoteInterface.optional')}
+                  </span>
+                )}
+              </div>
+              <div className="relative">
+                <select
+                  value={localRemoteInterfaceIp}
+                  onChange={(e) => handleRemoteInterfaceChange(e.target.value)}
+                  disabled={pendingAction === 'remoteInterface'}
+                  className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white appearance-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed pr-10"
+                >
+                  <option value="">{t('virtualPrinter.remoteInterface.placeholder')}</option>
+                  {networkInterfaces?.map((iface) => (
+                    <option key={iface.ip} value={iface.ip}>
+                      {iface.name} ({iface.ip}) - {iface.subnet}
+                    </option>
+                  ))}
+                </select>
+                <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
+              </div>
+              <p className="text-xs text-bambu-gray mt-2">
+                {t('virtualPrinter.remoteInterface.hint')}
+              </p>
+            </div>
+          )}
+
           {/* Mode */}
           {/* Mode */}
           <div className="py-3 border-t border-bambu-dark-tertiary">
           <div className="py-3 border-t border-bambu-dark-tertiary">
             <div className="text-white font-medium mb-2">{t('virtualPrinter.mode.title')}</div>
             <div className="text-white font-medium mb-2">{t('virtualPrinter.mode.title')}</div>
@@ -504,11 +558,11 @@ export function VirtualPrinterSettings() {
                   </div>
                   </div>
                   <div>
                   <div>
                     <div className="text-bambu-gray">{t('virtualPrinter.status.ftpConnections')}</div>
                     <div className="text-bambu-gray">{t('virtualPrinter.status.ftpConnections')}</div>
-                    <div className="text-white">{status.proxy.ftp_connections}</div>
+                    <div className="text-white">{status.proxy.ftp_connections ?? 0}</div>
                   </div>
                   </div>
                   <div>
                   <div>
                     <div className="text-bambu-gray">{t('virtualPrinter.status.mqttConnections')}</div>
                     <div className="text-bambu-gray">{t('virtualPrinter.status.mqttConnections')}</div>
-                    <div className="text-white">{status.proxy.mqtt_connections}</div>
+                    <div className="text-white">{status.proxy.mqtt_connections ?? 0}</div>
                   </div>
                   </div>
                 </div>
                 </div>
               ) : (
               ) : (

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

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

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

@@ -578,7 +578,7 @@ export default {
       object: '{{count}} Objekt',
       object: '{{count}} Objekt',
       objects: '{{count}} Objekte',
       objects: '{{count}} Objekte',
       slicedFor: 'Geslict für {{model}}',
       slicedFor: 'Geslict für {{model}}',
-      uploadedBy: 'Hochgeladen von {{name}}',
+      uploadedBy: 'Hochgeladen von',
       noPermissionReprint: 'Sie haben keine Berechtigung, erneut zu drucken',
       noPermissionReprint: 'Sie haben keine Berechtigung, erneut zu drucken',
       noPermissionEdit: 'Sie haben keine Berechtigung, Archive zu bearbeiten',
       noPermissionEdit: 'Sie haben keine Berechtigung, Archive zu bearbeiten',
       noPermissionDelete: 'Sie haben keine Berechtigung, Archive zu löschen',
       noPermissionDelete: 'Sie haben keine Berechtigung, Archive zu löschen',
@@ -687,6 +687,7 @@ export default {
       pending: 'Ausstehend',
       pending: 'Ausstehend',
       waiting: 'Wartend',
       waiting: 'Wartend',
       printing: 'Druckt',
       printing: 'Druckt',
+      paused: 'Pausiert',
       completed: 'Abgeschlossen',
       completed: 'Abgeschlossen',
       failed: 'Fehlgeschlagen',
       failed: 'Fehlgeschlagen',
       skipped: 'Übersprungen',
       skipped: 'Übersprungen',
@@ -1837,6 +1838,8 @@ export default {
     zipMayContainStl: 'ZIP-Dateien können STL-Dateien enthalten. Vorschaubilder können während der Extraktion generiert werden.',
     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.',
     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',
     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',
     willBeExtracted: 'Wird extrahiert',
     filesExtracted: '{{count}} Dateien extrahiert',
     filesExtracted: '{{count}} Dateien extrahiert',
     uploadComplete: 'Upload abgeschlossen: {{succeeded}} erfolgreich',
     uploadComplete: 'Upload abgeschlossen: {{succeeded}} erfolgreich',
@@ -1846,6 +1849,7 @@ export default {
     linkTo: 'Verknüpfen mit...',
     linkTo: 'Verknüpfen mit...',
     linkToProjectOrArchive: 'Mit Projekt oder Archiv verknüpfen',
     linkToProjectOrArchive: 'Mit Projekt oder Archiv verknüpfen',
     addToQueue: 'Zur Warteschlange',
     addToQueue: 'Zur Warteschlange',
+    schedulePrint: 'Planen',
     generateThumbnail: 'Vorschaubild generieren',
     generateThumbnail: 'Vorschaubild generieren',
     generateThumbnails: 'Vorschaubilder generieren',
     generateThumbnails: 'Vorschaubilder generieren',
     generateThumbnailsForMissing: 'Vorschaubilder für STL-Dateien ohne Vorschau 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.',
     noMatchingFilesDescription: 'Keine Dateien entsprechen Ihren aktuellen Such- oder Filterkriterien.',
     clearFilters: 'Filter zurücksetzen',
     clearFilters: 'Filter zurücksetzen',
     printedCount: '{{count}}x gedruckt',
     printedCount: '{{count}}x gedruckt',
-    uploadedBy: 'Hochgeladen von {{name}}',
+    uploadedBy: 'Hochgeladen von',
     deleteFolder: 'Ordner löschen',
     deleteFolder: 'Ordner löschen',
     deleteFile: 'Datei löschen',
     deleteFile: 'Datei löschen',
     deleteFilesCount: '{{count}} Dateien 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.',
       hint: 'Wähle den Drucker aus, an den der Slicer-Datenverkehr weitergeleitet werden soll. Der Drucker muss im LAN-Modus sein.',
       noPrinters: 'Keine Drucker konfiguriert. Füge zuerst einen Drucker hinzu, um den Proxy-Modus zu verwenden.',
       noPrinters: 'Keine Drucker konfiguriert. Füge zuerst einen Drucker hinzu, um den Proxy-Modus zu verwenden.',
     },
     },
+    remoteInterface: {
+      title: 'Slicer-Netzwerkschnittstelle',
+      configured: 'SSDP-Proxy aktiviert',
+      optional: 'Optional - für SSDP-Erkennung über Netzwerke hinweg',
+      placeholder: 'Schnittstelle für Slicer-Netzwerk auswählen...',
+      hint: 'Wähle die Netzwerkschnittstelle, die mit dem Slicer verbunden ist. Ermöglicht automatische Druckererkennung in Bambu Studio.',
+    },
     mode: {
     mode: {
       title: 'Modus',
       title: 'Modus',
       archive: 'Archivieren',
       archive: 'Archivieren',
@@ -2502,11 +2513,12 @@ export default {
       step4: 'Der "Bambuddy"-Drucker sollte in der Erkennungsliste erscheinen',
       step4: 'Der "Bambuddy"-Drucker sollte in der Erkennungsliste erscheinen',
       step5: 'Verbinde mit dem von dir gesetzten Zugangscode',
       step5: 'Verbinde mit dem von dir gesetzten Zugangscode',
       step6: 'Wenn du zu Bambuddy "druckst", wird die 3MF-Datei stattdessen archiviert',
       step6: 'Wenn du zu Bambuddy "druckst", wird die 3MF-Datei stattdessen archiviert',
-      proxyStep1: 'Setze die Zieldrucker-IP (muss in deinem LAN sein)',
-      proxyStep2: 'Konfiguriere Portweiterleitung zu Bambuddy (Ports 9990, 8883)',
-      proxyStep3: 'Füge in deinem Slicer manuell einen Netzwerkdrucker hinzu',
-      proxyStep4: 'Gib Bambuddys externe Adresse und den Zugangscode des Druckers ein',
-      proxyStep5: 'Drucke wie gewohnt - der Datenverkehr wird an den echten Drucker weitergeleitet',
+      proxyStep1: 'Wähle den Zieldrucker (muss im LAN-Modus sein)',
+      proxyStep2: 'Bei Netzwerkübergreifend: Wähle die Slicer-Netzwerkschnittstelle',
+      proxyStep3: 'Aktiviere den Proxy - Drucker erscheint per SSDP in der Slicer-Erkennung',
+      proxyStep4: 'Verbinde mit dem Zugangscode des Druckers',
+      proxyStep5: 'Drucke wie gewohnt - der Datenverkehr wird über Bambuddy weitergeleitet',
+      proxyStep6: 'Kamera-Streaming erfordert NAT/IP-Weiterleitung (siehe Dokumentation)',
     },
     },
     status: {
     status: {
       title: 'Status-Details',
       title: 'Status-Details',
@@ -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)
   // Maintenance type descriptions (built-in)
   maintenanceDescriptions: {
   maintenanceDescriptions: {
     lubricateRails: 'Schmiermittel auf Linearschienen für sanfte Bewegung auftragen',
     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',
       object: '{{count}} object',
       objects: '{{count}} objects',
       objects: '{{count}} objects',
       slicedFor: 'Sliced for {{model}}',
       slicedFor: 'Sliced for {{model}}',
-      uploadedBy: 'Uploaded by {{name}}',
+      uploadedBy: 'Uploaded By',
       noPermissionReprint: 'You do not have permission to reprint',
       noPermissionReprint: 'You do not have permission to reprint',
       noPermissionEdit: 'You do not have permission to edit archives',
       noPermissionEdit: 'You do not have permission to edit archives',
       noPermissionDelete: 'You do not have permission to delete archives',
       noPermissionDelete: 'You do not have permission to delete archives',
@@ -687,6 +687,7 @@ export default {
       pending: 'Pending',
       pending: 'Pending',
       waiting: 'Waiting',
       waiting: 'Waiting',
       printing: 'Printing',
       printing: 'Printing',
+      paused: 'Paused',
       completed: 'Completed',
       completed: 'Completed',
       failed: 'Failed',
       failed: 'Failed',
       skipped: 'Skipped',
       skipped: 'Skipped',
@@ -1837,6 +1838,8 @@ export default {
     zipMayContainStl: 'ZIP files may contain STL files. Thumbnails can be generated during extraction.',
     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.',
     thumbnailsCanBeGenerated: 'Thumbnails can be generated for STL files. Large models may take longer to process.',
     generateThumbnailsForStl: 'Generate thumbnails for STL files',
     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',
     willBeExtracted: 'Will be extracted',
     filesExtracted: '{{count}} files extracted',
     filesExtracted: '{{count}} files extracted',
     uploadComplete: 'Upload complete: {{succeeded}} succeeded',
     uploadComplete: 'Upload complete: {{succeeded}} succeeded',
@@ -1846,6 +1849,7 @@ export default {
     linkTo: 'Link to...',
     linkTo: 'Link to...',
     linkToProjectOrArchive: 'Link to project or archive',
     linkToProjectOrArchive: 'Link to project or archive',
     addToQueue: 'Add to Queue',
     addToQueue: 'Add to Queue',
+    schedulePrint: 'Schedule',
     generateThumbnail: 'Generate Thumbnail',
     generateThumbnail: 'Generate Thumbnail',
     generateThumbnails: 'Generate Thumbnails',
     generateThumbnails: 'Generate Thumbnails',
     generateThumbnailsForMissing: 'Generate thumbnails for STL files missing them',
     generateThumbnailsForMissing: 'Generate thumbnails for STL files missing them',
@@ -1881,7 +1885,7 @@ export default {
     noMatchingFilesDescription: 'No files match your current search or filter criteria.',
     noMatchingFilesDescription: 'No files match your current search or filter criteria.',
     clearFilters: 'Clear filters',
     clearFilters: 'Clear filters',
     printedCount: 'Printed {{count}}x',
     printedCount: 'Printed {{count}}x',
-    uploadedBy: 'Uploaded by {{name}}',
+    uploadedBy: 'Uploaded By',
     deleteFolder: 'Delete Folder',
     deleteFolder: 'Delete Folder',
     deleteFile: 'Delete File',
     deleteFile: 'Delete File',
     deleteFilesCount: 'Delete {{count}} Files',
     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.',
       hint: 'Select the printer to proxy slicer traffic to. The printer must be in LAN mode.',
       noPrinters: 'No printers configured. Add a printer first to use proxy mode.',
       noPrinters: 'No printers configured. Add a printer first to use proxy mode.',
     },
     },
+    remoteInterface: {
+      title: 'Slicer Network Interface',
+      configured: 'SSDP proxy enabled',
+      optional: 'Optional - for SSDP discovery across networks',
+      placeholder: 'Select interface for slicer network...',
+      hint: 'Select the network interface connected to the slicer. Enables automatic printer discovery in Bambu Studio.',
+    },
     mode: {
     mode: {
       title: 'Mode',
       title: 'Mode',
       archive: 'Archive',
       archive: 'Archive',
@@ -2502,11 +2513,12 @@ export default {
       step4: 'The "Bambuddy" printer should appear in the discovery list',
       step4: 'The "Bambuddy" printer should appear in the discovery list',
       step5: 'Connect using the access code you set',
       step5: 'Connect using the access code you set',
       step6: 'When you "print" to Bambuddy, the 3MF file is archived instead',
       step6: 'When you "print" to Bambuddy, the 3MF file is archived instead',
-      proxyStep1: 'Set the target printer IP (must be on your LAN)',
-      proxyStep2: 'Configure port forwarding to Bambuddy (ports 9990, 8883)',
-      proxyStep3: 'In your slicer, manually add a network printer',
-      proxyStep4: "Enter Bambuddy's external address and printer's access code",
-      proxyStep5: 'Print as normal - traffic is relayed to the real printer',
+      proxyStep1: 'Select the target printer (must be in LAN mode)',
+      proxyStep2: 'For cross-network: select the slicer network interface',
+      proxyStep3: 'Enable the proxy - printer appears in slicer discovery via SSDP',
+      proxyStep4: 'Connect using the printer\'s access code',
+      proxyStep5: 'Print as normal - traffic is relayed through Bambuddy',
+      proxyStep6: 'Camera streaming requires NAT/IP forwarding (see docs)',
     },
     },
     status: {
     status: {
       title: 'Status Details',
       title: 'Status Details',
@@ -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)
   // Maintenance type descriptions (built-in)
   maintenanceDescriptions: {
   maintenanceDescriptions: {
     lubricateRails: 'Apply lubricant to linear rails for smooth motion',
     lubricateRails: 'Apply lubricant to linear rails for smooth motion',

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

@@ -549,6 +549,7 @@ export default {
     status: {
     status: {
       pending: '待機中',
       pending: '待機中',
       printing: '印刷中',
       printing: '印刷中',
+      paused: '一時停止',
       completed: '完了',
       completed: '完了',
       failed: '失敗',
       failed: '失敗',
       skipped: 'スキップ',
       skipped: 'スキップ',
@@ -1473,6 +1474,35 @@ export default {
       model: 'モデル',
       model: 'モデル',
       serialNumber: 'シリアルナンバー',
       serialNumber: 'シリアルナンバー',
       pendingFiles: '保留中のファイル',
       pendingFiles: '保留中のファイル',
+      modeProxy: 'プロキシ',
+      modeProxyDesc: '実際のプリンターに中継',
+      descriptionProxy: 'スライサーのトラフィックを実際のプリンターに中継するプロキシを有効にし、任意のネットワーク経由でリモート印刷を可能にします。',
+      proxyingTo: '{{name}}にプロキシ中',
+      notActive: '非アクティブ',
+      targetPrinterTitle: 'ターゲットプリンター',
+      targetPrinterConfigured: 'プロキシターゲット設定済み',
+      targetPrinterNotConfigured: 'ターゲットプリンター未選択 - プロキシモードに必要です',
+      targetPrinterPlaceholder: 'プリンターを選択...',
+      targetPrinterHint: 'スライサーのトラフィックを中継するプリンターを選択します。プリンターはLANモードである必要があります。',
+      targetPrinterNoPrinters: 'プリンターが設定されていません。プロキシモードを使用するには、まずプリンターを追加してください。',
+      remoteInterfaceTitle: 'スライサーネットワークインターフェース',
+      remoteInterfaceConfigured: 'SSDPプロキシ有効',
+      remoteInterfaceOptional: 'オプション - ネットワーク間のSSDPディスカバリー用',
+      remoteInterfacePlaceholder: 'スライサーネットワークのインターフェースを選択...',
+      remoteInterfaceHint: 'スライサーに接続されているネットワークインターフェースを選択します。Bambu Studioでの自動プリンター検出を有効にします。',
+      targetPrinterRequired: '先にターゲットプリンターを選択してください',
+      ftpPort: 'FTPポート',
+      mqttPort: 'MQTTポート',
+      ftpConnections: 'FTP接続数',
+      mqttConnections: 'MQTT接続数',
+      targetPrinterStatus: 'ターゲットプリンター',
+      howItWorksProxy: '仕組み(プロキシモード)',
+      proxyStep1: 'ターゲットプリンターを選択(LANモードである必要があります)',
+      proxyStep2: 'クロスネットワーク時:スライサーネットワークインターフェースを選択',
+      proxyStep3: 'プロキシを有効化 - プリンターがSSDPでスライサー検出に表示されます',
+      proxyStep4: 'プリンターのアクセスコードで接続',
+      proxyStep5: '通常通り印刷 - トラフィックはBambuddyを経由して中継されます',
+      proxyStep6: 'カメラストリーミングにはNAT/IP転送が必要です(ドキュメント参照)',
     },
     },
     enterNewCodeToChange: '新しいコードを入力して変更',
     enterNewCodeToChange: '新しいコードを入力して変更',
     enter8CharCode: '8文字のコードを入力',
     enter8CharCode: '8文字のコードを入力',
@@ -1885,6 +1915,7 @@ export default {
     linkTo: 'リンク先...',
     linkTo: 'リンク先...',
     printedCount: '{{count}}回印刷済み',
     printedCount: '{{count}}回印刷済み',
     addToQueue: 'キューに追加',
     addToQueue: 'キューに追加',
+    schedulePrint: '印刷をスケジュール',
     adding: '追加中...',
     adding: '追加中...',
     folderCreated: 'フォルダを作成しました',
     folderCreated: 'フォルダを作成しました',
     folderDeleted: 'フォルダを削除しました',
     folderDeleted: 'フォルダを削除しました',
@@ -3043,4 +3074,36 @@ export default {
     failedToCreateBackup: 'バックアップの作成に失敗しました',
     failedToCreateBackup: 'バックアップの作成に失敗しました',
     backupRestored: 'バックアップの復元が完了しました',
     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.');
   return lower.endsWith('.gcode') || lower.includes('.gcode.');
 }
 }
 
 
+function getArchiveFileType(filename: string | null | undefined): string | undefined {
+  if (!filename) return undefined;
+  const lower = filename.toLowerCase();
+  if (lower.endsWith('.3mf')) return '3mf';
+  if (lower.endsWith('.stl')) return 'stl';
+  if (lower.endsWith('.gcode') || lower.includes('.gcode.')) return 'gcode';
+  return lower.split('.').pop();
+}
+
 // formatDate imported from '../utils/date' - handles UTC conversion
 // formatDate imported from '../utils/date' - handles UTC conversion
 
 
 function ArchiveCard({
 function ArchiveCard({
@@ -1040,6 +1049,7 @@ function ArchiveCard({
         <ModelViewerModal
         <ModelViewerModal
           archiveId={archive.id}
           archiveId={archive.id}
           title={archive.print_name || archive.filename}
           title={archive.print_name || archive.filename}
+          fileType={getArchiveFileType(archive.filename)}
           onClose={() => setShowViewer(false)}
           onClose={() => setShowViewer(false)}
         />
         />
       )}
       )}
@@ -1833,6 +1843,7 @@ function ArchiveListRow({
         <ModelViewerModal
         <ModelViewerModal
           archiveId={archive.id}
           archiveId={archive.id}
           title={archive.print_name || archive.filename}
           title={archive.print_name || archive.filename}
+          fileType={getArchiveFileType(archive.filename)}
           onClose={() => setShowViewer(false)}
           onClose={() => setShowViewer(false)}
         />
         />
       )}
       )}
@@ -2522,7 +2533,7 @@ export function ArchivesPage() {
         </div>
         </div>
       )}
       )}
 
 
-      <div className="flex items-center justify-between mb-8">
+      <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
         <div>
         <div>
           <div className="flex items-center gap-3">
           <div className="flex items-center gap-3">
             <h1 className="text-2xl font-bold text-white">Archives</h1>
             <h1 className="text-2xl font-bold text-white">Archives</h1>
@@ -2542,7 +2553,7 @@ export function ArchivesPage() {
             {filteredArchives?.length || 0} of {archives?.length || 0} prints
             {filteredArchives?.length || 0} of {archives?.length || 0} prints
           </p>
           </p>
         </div>
         </div>
-        <div className="flex items-center gap-3">
+        <div className="flex items-center gap-2 sm:gap-3 flex-wrap">
           {/* Export dropdown */}
           {/* Export dropdown */}
           <div className="relative">
           <div className="relative">
             <Button
             <Button

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

@@ -37,6 +37,8 @@ import {
   Pencil,
   Pencil,
   Play,
   Play,
   Image,
   Image,
+  User,
+  Box,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type {
 import type {
@@ -51,6 +53,7 @@ import type {
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { PrintModal } from '../components/PrintModal';
 import { PrintModal } from '../components/PrintModal';
+import { ModelViewerModal } from '../components/ModelViewerModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
@@ -429,6 +432,7 @@ interface UploadFile {
   status: 'pending' | 'uploading' | 'success' | 'error';
   status: 'pending' | 'uploading' | 'success' | 'error';
   error?: string;
   error?: string;
   isZip?: boolean;
   isZip?: boolean;
+  is3mf?: boolean;
   extractedCount?: number;
   extractedCount?: number;
 }
 }
 
 
@@ -469,6 +473,7 @@ function UploadModal({ folderId, onClose, onUploadComplete, t }: UploadModalProp
       file,
       file,
       status: 'pending',
       status: 'pending',
       isZip: file.name.toLowerCase().endsWith('.zip'),
       isZip: file.name.toLowerCase().endsWith('.zip'),
+      is3mf: file.name.toLowerCase().endsWith('.3mf'),
     }));
     }));
     setFiles((prev) => [...prev, ...uploadFiles]);
     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 hasZipFiles = files.some((f) => f.isZip && f.status === 'pending');
   const hasStlFiles = files.some((f) => f.file.name.toLowerCase().endsWith('.stl') && 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 () => {
   const handleUpload = async () => {
     if (files.length === 0) return;
     if (files.length === 0) return;
 
 
     setIsUploading(true);
     setIsUploading(true);
 
 
+    // Handle all files with library upload (ZIP and regular files including .3mf)
     for (let i = 0; i < files.length; i++) {
     for (let i = 0; i < files.length; i++) {
       if (files[i].status !== 'pending') continue;
       if (files[i].status !== 'pending') continue;
 
 
@@ -509,7 +516,7 @@ function UploadModal({ folderId, onClose, onUploadComplete, t }: UploadModalProp
             )
             )
           );
           );
         } else {
         } else {
-          // Regular file upload
+          // Regular file upload (STL, .3mf, etc.) - .3mf files automatically get metadata extracted
           await api.uploadLibraryFile(files[i].file, folderId, generateStlThumbnails);
           await api.uploadLibraryFile(files[i].file, folderId, generateStlThumbnails);
           setFiles((prev) =>
           setFiles((prev) =>
             prev.map((f, idx) => (idx === i ? { ...f, status: 'success' } : f))
             prev.map((f, idx) => (idx === i ? { ...f, status: 'success' } : f))
@@ -609,6 +616,21 @@ function UploadModal({ folderId, onClose, onUploadComplete, t }: UploadModalProp
             </div>
             </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) */}
           {/* STL Thumbnail Options - show for STL files or ZIP files (which may contain STLs) */}
           {(hasStlFiles || hasZipFiles) && (
           {(hasStlFiles || hasZipFiles) && (
             <div className="p-3 bg-bambu-green/10 border border-bambu-green/30 rounded-lg">
             <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;
   onDownload: (id: number) => void;
   onAddToQueue?: (id: number) => void;
   onAddToQueue?: (id: number) => void;
   onPrint?: (file: LibraryFileListItem) => void;
   onPrint?: (file: LibraryFileListItem) => void;
+  onPreview3d?: (file: LibraryFileListItem) => void;
   onRename?: (file: LibraryFileListItem) => void;
   onRename?: (file: LibraryFileListItem) => void;
   onGenerateThumbnail?: (file: LibraryFileListItem) => void;
   onGenerateThumbnail?: (file: LibraryFileListItem) => void;
   thumbnailVersion?: number;
   thumbnailVersion?: number;
   hasPermission: (permission: Permission) => boolean;
   hasPermission: (permission: Permission) => boolean;
   canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
   canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
+  authEnabled: boolean;
   t: TFunction;
   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);
   const [showActions, setShowActions] = useState(false);
 
 
   return (
   return (
     <div
     <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
         isSelected
           ? 'border-bambu-green ring-1 ring-bambu-green'
           ? 'border-bambu-green ring-1 ring-bambu-green'
           : 'border-bambu-dark-tertiary hover:border-bambu-green/50'
           : 'border-bambu-dark-tertiary hover:border-bambu-green/50'
@@ -943,14 +967,21 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
             </span>
             </span>
           )}
           )}
         </div>
         </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 && (
         {file.print_count > 0 && (
           <div className="mt-1 text-xs text-bambu-green">
           <div className="mt-1 text-xs text-bambu-green">
             {t('fileManager.printedCount', { count: file.print_count })}
             {t('fileManager.printedCount', { count: file.print_count })}
           </div>
           </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>
         )}
         )}
       </div>
       </div>
@@ -990,7 +1021,20 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
                   title={!hasPermission('queue:create') ? t('fileManager.noPermissionAddToQueue') : undefined}
                   title={!hasPermission('queue:create') ? t('fileManager.noPermissionAddToQueue') : undefined}
                 >
                 >
                   <Clock className="w-3.5 h-3.5" />
                   <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>
               )}
               )}
               <button
               <button
@@ -1062,7 +1106,7 @@ export function FileManagerPage() {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
-  const { hasPermission, hasAnyPermission, canModify } = useAuth();
+  const { hasPermission, hasAnyPermission, canModify, authEnabled } = useAuth();
   const [searchParams] = useSearchParams();
   const [searchParams] = useSearchParams();
 
 
   // Read folder ID from URL query parameter
   // 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 [deleteConfirm, setDeleteConfirm] = useState<{ type: 'file' | 'folder' | 'bulk'; id: number; count?: number } | null>(null);
   const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);
   const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);
   const [printMultiFile, setPrintMultiFile] = 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 [renameItem, setRenameItem] = useState<{ type: 'file' | 'folder'; id: number; name: string } | null>(null);
   const [thumbnailVersions, setThumbnailVersions] = useState<Record<number, number>>({});
   const [thumbnailVersions, setThumbnailVersions] = useState<Record<number, number>>({});
+  const [viewerFile, setViewerFile] = useState<LibraryFileListItem | null>(null);
   const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
   const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
     return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
     return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
   });
   });
@@ -1137,6 +1183,7 @@ export function FileManagerPage() {
   // Filter and sort state (persist sort preferences to localStorage)
   // Filter and sort state (persist sort preferences to localStorage)
   const [searchQuery, setSearchQuery] = useState('');
   const [searchQuery, setSearchQuery] = useState('');
   const [filterType, setFilterType] = useState<string>('all');
   const [filterType, setFilterType] = useState<string>('all');
+  const [filterUsername, setFilterUsername] = useState('');
   const [sortField, setSortField] = useState<SortField>(() => {
   const [sortField, setSortField] = useState<SortField>(() => {
     const saved = localStorage.getItem('library-sort-field');
     const saved = localStorage.getItem('library-sort-field');
     return (saved as SortField) || 'name';
     return (saved as SortField) || 'name';
@@ -1178,6 +1225,12 @@ export function FileManagerPage() {
     queryFn: () => api.getLibraryStats(),
     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
   // Get unique file types for filter dropdown
   const fileTypes = useMemo(() => {
   const fileTypes = useMemo(() => {
     if (!files) return [];
     if (!files) return [];
@@ -1206,6 +1259,14 @@ export function FileManagerPage() {
       result = result.filter((f) => f.file_type === filterType);
       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
     // Apply sorting
     result.sort((a, b) => {
     result.sort((a, b) => {
       let comparison = 0;
       let comparison = 0;
@@ -1230,7 +1291,7 @@ export function FileManagerPage() {
     });
     });
 
 
     return result;
     return result;
-  }, [files, searchQuery, filterType, sortField, sortDirection]);
+  }, [files, searchQuery, filterType, filterUsername, sortField, sortDirection]);
 
 
   // Check if disk space is low
   // Check if disk space is low
   const isDiskSpaceLow = useMemo(() => {
   const isDiskSpaceLow = useMemo(() => {
@@ -1328,31 +1389,6 @@ export function FileManagerPage() {
     onError: (error: Error) => showToast(error.message, 'error'),
     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({
   const renameFileMutation = useMutation({
     mutationFn: ({ id, filename }: { id: number; filename: string }) =>
     mutationFn: ({ id, filename }: { id: number; filename: string }) =>
       api.updateLibraryFile(id, { filename }),
       api.updateLibraryFile(id, { filename }),
@@ -1512,7 +1548,7 @@ export function FileManagerPage() {
             <button
             <button
               onClick={() => handleViewModeChange('grid')}
               onClick={() => handleViewModeChange('grid')}
               className={`p-1.5 rounded transition-colors ${
               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')}
               title={t('fileManager.gridView')}
             >
             >
@@ -1521,7 +1557,7 @@ export function FileManagerPage() {
             <button
             <button
               onClick={() => handleViewModeChange('list')}
               onClick={() => handleViewModeChange('list')}
               className={`p-1.5 rounded transition-colors ${
               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')}
               title={t('fileManager.listView')}
             >
             >
@@ -1576,7 +1612,7 @@ export function FileManagerPage() {
 
 
       {/* Stats bar */}
       {/* Stats bar */}
       {stats && (
       {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">
           <div className="flex items-center gap-2 text-sm">
             <File className="w-4 h-4 text-bambu-green" />
             <File className="w-4 h-4 text-bambu-green" />
             <span className="text-bambu-gray">{t('fileManager.files')}:</span>
             <span className="text-bambu-gray">{t('fileManager.files')}:</span>
@@ -1608,7 +1644,7 @@ export function FileManagerPage() {
           <select
           <select
             value={selectedFolderId ?? ''}
             value={selectedFolderId ?? ''}
             onChange={(e) => setSelectedFolderId(e.target.value ? parseInt(e.target.value, 10) : null)}
             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>
             <option value="">📁 {t('fileManager.allFiles')}</option>
             {folders && (() => {
             {folders && (() => {
@@ -1635,7 +1671,7 @@ export function FileManagerPage() {
         {/* Folder sidebar - resizable, hidden on mobile */}
         {/* Folder sidebar - resizable, hidden on mobile */}
         <div
         <div
           ref={sidebarRef}
           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` }}
           style={{ width: `${sidebarWidth}px` }}
         >
         >
           {/* Resize handle - drag to resize, double-click to reset */}
           {/* 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">
         <div className="flex-1 flex flex-col min-w-0 min-h-0">
           {/* Search, Filter, Sort toolbar - sticky on mobile for easier access */}
           {/* Search, Filter, Sort toolbar - sticky on mobile for easier access */}
           {files && files.length > 0 && (
           {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 */}
               {/* Search */}
               <div className="relative w-full sm:w-auto sm:flex-1 sm:max-w-xs">
               <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" />
                 <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>
                 </select>
               </div>
               </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 */}
               {/* Sort */}
               <div className="flex items-center gap-2">
               <div className="flex items-center gap-2">
                 <select
                 <select
@@ -1779,7 +1843,7 @@ export function FileManagerPage() {
               </div>
               </div>
 
 
               {/* Results count */}
               {/* Results count */}
-              {(searchQuery || filterType !== 'all') && (
+              {(searchQuery || filterType !== 'all' || filterUsername) && (
                 <span className="text-sm text-bambu-gray hidden sm:inline">
                 <span className="text-sm text-bambu-gray hidden sm:inline">
                   {t('fileManager.resultsCount', { showing: filteredAndSortedFiles.length, total: files.length })}
                   {t('fileManager.resultsCount', { showing: filteredAndSortedFiles.length, total: files.length })}
                 </span>
                 </span>
@@ -1789,7 +1853,7 @@ export function FileManagerPage() {
 
 
           {/* Selection toolbar - sticky on mobile below search bar */}
           {/* Selection toolbar - sticky on mobile below search bar */}
           {filteredAndSortedFiles.length > 0 && (
           {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 */}
               {/* Select all / Deselect all */}
               {selectedFiles.length === filteredAndSortedFiles.length && selectedFiles.length > 0 ? (
               {selectedFiles.length === filteredAndSortedFiles.length && selectedFiles.length > 0 ? (
                 <Button
                 <Button
@@ -1830,16 +1894,19 @@ export function FileManagerPage() {
                         <span className="hidden sm:inline">{t('common.print')}</span>
                         <span className="hidden sm:inline">{t('common.print')}</span>
                       </Button>
                       </Button>
                     )}
                     )}
-                    {selectedSlicedFiles.length > 0 && (
+                    {selectedSlicedFiles.length === 1 && (
                       <Button
                       <Button
-                        variant={selectedSlicedFiles.length === 1 ? 'secondary' : 'primary'}
+                        variant="secondary"
                         size="sm"
                         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}
                         title={!hasPermission('queue:create') ? t('fileManager.noPermissionAddToQueue') : undefined}
                       >
                       >
                         <Clock className="w-4 h-4 sm:mr-1" />
                         <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>
                     )}
                     )}
                     <Button
                     <Button
@@ -1938,24 +2005,30 @@ export function FileManagerPage() {
                     onSelect={handleFileSelect}
                     onSelect={handleFileSelect}
                     onDelete={(id) => setDeleteConfirm({ type: 'file', id })}
                     onDelete={(id) => setDeleteConfirm({ type: 'file', id })}
                     onDownload={handleDownload}
                     onDownload={handleDownload}
-                    onAddToQueue={(id) => addToQueueMutation.mutate([id])}
+                    onAddToQueue={(id) => {
+                      const file = files?.find(f => f.id === id);
+                      if (file) setScheduleFile(file);
+                    }}
                     onPrint={setPrintFile}
                     onPrint={setPrintFile}
+                    onPreview3d={setViewerFile}
                     onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
                     onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
                     onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
                     onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
                     thumbnailVersion={thumbnailVersions[file.id]}
                     thumbnailVersion={thumbnailVersions[file.id]}
                     hasPermission={hasPermission}
                     hasPermission={hasPermission}
                     canModify={canModify}
                     canModify={canModify}
+                    authEnabled={authEnabled}
                   />
                   />
                 ))}
                 ))}
               </div>
               </div>
             </div>
             </div>
           ) : (
           ) : (
             <div className="flex-1 lg:overflow-y-auto">
             <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 */}
                 {/* 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 className="w-6" />
                   <div>{t('common.name')}</div>
                   <div>{t('common.name')}</div>
+                  {authEnabled && <div>{t('fileManager.uploadedBy', { defaultValue: 'Uploaded By' })}</div>}
                   <div>{t('common.type')}</div>
                   <div>{t('common.type')}</div>
                   <div>{t('fileManager.size')}</div>
                   <div>{t('fileManager.size')}</div>
                   <div>{t('fileManager.prints')}</div>
                   <div>{t('fileManager.prints')}</div>
@@ -1965,7 +2038,7 @@ export function FileManagerPage() {
                 {filteredAndSortedFiles.map((file) => (
                 {filteredAndSortedFiles.map((file) => (
                   <div
                   <div
                     key={file.id}
                     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' : ''
                       selectedFiles.includes(file.id) ? 'bg-bambu-green/10' : ''
                     }`}
                     }`}
                     onClick={() => handleFileSelect(file.id)}
                     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 className="text-sm text-white truncate">{file.print_name || file.filename}</div>
                       </div>
                       </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 */}
                     {/* Type */}
                     <div>
                     <div>
                       <span className={`text-xs px-1.5 py-0.5 rounded font-medium ${
                       <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" />
                             <Printer className="w-4 h-4" />
                           </button>
                           </button>
                           <button
                           <button
-                            onClick={() => hasPermission('queue:create') && addToQueueMutation.mutate([file.id])}
+                            onClick={() => {
+                              if (hasPermission('queue:create')) {
+                                setScheduleFile(file);
+                              }
+                            }}
                             className={`p-1.5 rounded transition-colors ${
                             className={`p-1.5 rounded transition-colors ${
                               hasPermission('queue:create')
                               hasPermission('queue:create')
                                 ? 'hover:bg-bambu-dark text-bambu-gray hover:text-white'
                                 ? 'hover:bg-bambu-dark text-bambu-gray hover:text-white'
                                 : 'text-bambu-gray/50 cursor-not-allowed'
                                 : '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" />
                             <Clock className="w-4 h-4" />
                           </button>
                           </button>
                         </>
                         </>
                       )}
                       )}
+                      {(file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'stl') && (
+                        <button
+                          onClick={() => hasPermission('library:read') && setViewerFile(file)}
+                          className={`p-1.5 rounded transition-colors ${
+                            hasPermission('library:read')
+                              ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
+                              : 'text-bambu-gray/50 cursor-not-allowed'
+                          }`}
+                          title={hasPermission('library:read') ? '3D Preview' : 'You do not have permission to preview files'}
+                          disabled={!hasPermission('library:read')}
+                        >
+                          <Box className="w-4 h-4" />
+                        </button>
+                      )}
                       <button
                       <button
                         onClick={() => hasPermission('library:read') && handleDownload(file.id)}
                         onClick={() => hasPermission('library:read') && handleDownload(file.id)}
                         className={`p-1.5 rounded transition-colors ${
                         className={`p-1.5 rounded transition-colors ${
@@ -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 && (
       {renameItem && (
         <RenameModal
         <RenameModal
           type={renameItem.type}
           type={renameItem.type}

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

@@ -1603,7 +1603,7 @@ function PrinterCard({
                 <MoreVertical className="w-4 h-4" />
                 <MoreVertical className="w-4 h-4" />
               </Button>
               </Button>
               {showMenu && (
               {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
                   <button
                     className={`w-full px-4 py-2 text-left text-sm flex items-center gap-2 ${
                     className={`w-full px-4 py-2 text-left text-sm flex items-center gap-2 ${
                       hasPermission('printers:update')
                       hasPermission('printers:update')

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

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

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

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

+ 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.
  * Parse a date string based on format setting.
  * Returns null if parsing fails.
  * Returns null if parsing fails.
+ * Supports common separators: / . -
  */
  */
 export function parseDateInput(value: string, dateFormat: DateFormat = 'system'): Date | null {
 export function parseDateInput(value: string, dateFormat: DateFormat = 'system'): Date | null {
   if (!value) return null;
   if (!value) return null;
@@ -106,27 +119,27 @@ export function parseDateInput(value: string, dateFormat: DateFormat = 'system')
   try {
   try {
     switch (dateFormat) {
     switch (dateFormat) {
       case 'us': {
       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);
         month = parseInt(parts[0], 10);
         day = parseInt(parts[1], 10);
         day = parseInt(parts[1], 10);
         year = parseInt(parts[2], 10);
         year = parseInt(parts[2], 10);
         break;
         break;
       }
       }
       case 'eu': {
       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);
         day = parseInt(parts[0], 10);
         month = parseInt(parts[1], 10);
         month = parseInt(parts[1], 10);
         year = parseInt(parts[2], 10);
         year = parseInt(parts[2], 10);
         break;
         break;
       }
       }
       case 'iso': {
       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);
         year = parseInt(parts[0], 10);
         month = parseInt(parts[1], 10);
         month = parseInt(parts[1], 10);
         day = parseInt(parts[2], 10);
         day = parseInt(parts[2], 10);
@@ -134,15 +147,29 @@ export function parseDateInput(value: string, dateFormat: DateFormat = 'system')
       }
       }
       case 'system':
       case 'system':
       default: {
       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;
           break;
         }
         }
         return null;
         return null;

+ 0 - 7
frontend/vitest.config.ts

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

+ 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


Fichier diff supprimé car celui-ci est trop grand
+ 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


Fichier diff supprimé car celui-ci est trop grand
+ 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>

Fichier diff supprimé car celui-ci est trop grand
+ 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>

Fichier diff supprimé car celui-ci est trop grand
+ 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>

Fichier diff supprimé car celui-ci est trop grand
+ 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>

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