Browse Source

Merge pull request #81 from maziggy/0.1.6b7

v0.1.6b7

  ## AMS Color Mapping (#76)
  - Manual AMS slot selection in ReprintModal, AddToQueueModal, EditQueueItemModal
  - Dropdown to override auto-matched AMS slots with any loaded filament
  - Blue ring indicator distinguishes manual selections from auto-matches
  - Status indicators: green (match), yellow (type only), orange (not found)
  - Shared color utility (utils/colors.ts) with ~200 Bambu color mappings
  - Widened modals (max-w-md → max-w-lg) to fit long color names
  - Fixed AMS mapping format to match Bambu Studio exactly:
    - Use slot_id from 3MF to position tray IDs correctly
    - Fill unused slots with -1 placeholder
    - Add ams_mapping2 with detailed {ams_id, slot_id} pairs
  - Resolves "stuck at filament change" on multi-color reprints

  ## Print Options in Reprint Modal
  - Bed leveling, flow calibration, vibration calibration toggles
  - First layer inspection, timelapse toggles
  - Collapsible "Print Options" section
  - Fixed print command format for proper printer recognition
  
  ## Time Format Setting (#75)
  - New date utilities (utils/date.ts): parseUTCDate, formatDate, formatDateTime
  - Applied to 12 components: PrintersPage, ArchivesPage, QueuePage,
    ProjectDetailPage, SystemInfoPage, AMSHistoryModal, NotificationLogViewer,
    CalendarView, FilamentTrends, PrinterQueueWidget, ProfilesPage
  - Fixes archive times showing in UTC instead of local timezone

  ## Statistics Dashboard (#72)
  - Size-aware rendering passes widget size (1/4, 1/2, full) to components
  - PrintCalendar: Dynamic cell sizing via ResizeObserver, 3/6/12 months
  - SuccessRateWidget: Per-printer breakdown at larger sizes
  - TimeAccuracyWidget: Per-printer breakdown at larger sizes
  - FilamentTypesWidget: Grid layout at larger sizes
  - FailureAnalysisWidget: More failure reasons at larger sizes

  ## Firmware Update Helper
  - Check firmware versions against Bambu Lab servers for LAN-only printers
  - Orange "Update" badge on printer cards when updates available
  - Firmware modal with version info, release notes, upload progress
  - One-click upload to printer SD card via FTP with progress callback
  - Local firmware caching for faster re-uploads
  - New services: firmware_check.py, firmware_update.py
  - New routes: /api/v1/firmware/*

  ## FTP Reliability
  - Configurable retry (1-10 attempts, 1-30s delay) in Settings > General
  - Applies to: 3MF archiving, print uploads, timelapse downloads,
    reprint uploads, firmware updates
  - A1/A1 Mini SSL fix: skip SSL on data channel entirely (control encrypted)
  - X1C/P1S: SSL with session reuse on data channel (required by vsFTPd)
  - Configurable FTP timeout (10-120s, default 30s) for slow WiFi

  ## Bulk Operations
  - Bulk project assignment from multi-select toolbar (#70)
  - BatchProjectModal for assigning archives to projects
  - "Remove from project" option for bulk clearing

  ## Chamber Light Control (#67)
  - Light toggle button on printer cards (next to camera)
  - Custom ChamberLight SVG icon with on/off states
  - Backend: POST /printers/{id}/chamber-light?on=true|false
  - H2D dual chamber lights controlled together

  ## Support Bundle Feature
  - Debug logging toggle with real-time duration timer in Layout
  - /api/v1/support/bundle generates ZIP with system info and logs
  - Privacy protection: filters secrets, sanitizes paths, removes IPs/emails
  - Requires debug logging enabled before download

  ## Archive Improvements
  - List view: edit/delete buttons and context menu with full parity
  - Object count display on cards (extracted from 3MF metadata)
  - Cross-view highlighting: click calendar/project to highlight in cards
  - Context menu button (⋮) on cards and list rows

  ## Maintenance Improvements (#59)
  - wiki_url field for custom type documentation links
  - Model-specific Bambu Lab wiki URLs for system types
  - External link icon next to maintenance item names
  - Fix: deleted printer now removed from maintenance

  ## Spoolman Integration
  - Clear location when spools removed from AMS during sync
  - find_spools_by_location_prefix() to find spools at a printer
  - clear_location_for_removed_spools() clears stale locations

  ## Bug Fixes
  - Browser freeze from CameraPage WebSocket (removed duplicate connection)
  - Project card filament badges showing duplicates and raw color codes
  - Print object label positioning in skip objects modal
  - Printer hour counter not updated on backend restart
  - Virtual printer excluded from discovery
  - Print cover fetch in Docker environments
  - Critical: Archive delete safety checks prevent deleting parent dirs
MartinNYHC 4 months ago
parent
commit
22dbdd7378
80 changed files with 6544 additions and 699 deletions
  1. 3 0
      .gitignore
  2. 110 0
      CHANGELOG.md
  3. 7 4
      README.md
  4. 128 20
      backend/app/api/routes/archives.py
  5. 78 0
      backend/app/api/routes/cloud.py
  6. 298 0
      backend/app/api/routes/firmware.py
  7. 15 1
      backend/app/api/routes/maintenance.py
  8. 32 1
      backend/app/api/routes/print_queue.py
  9. 45 1
      backend/app/api/routes/printers.py
  10. 8 1
      backend/app/api/routes/settings.py
  11. 30 0
      backend/app/api/routes/spoolman.py
  12. 347 0
      backend/app/api/routes/support.py
  13. 1 1
      backend/app/core/config.py
  14. 12 0
      backend/app/core/database.py
  15. 102 49
      backend/app/main.py
  16. 1 0
      backend/app/models/maintenance.py
  17. 4 0
      backend/app/models/print_queue.py
  18. 29 1
      backend/app/schemas/archive.py
  19. 18 0
      backend/app/schemas/cloud.py
  20. 5 0
      backend/app/schemas/maintenance.py
  21. 5 0
      backend/app/schemas/print_queue.py
  22. 2 0
      backend/app/schemas/printer.py
  23. 8 0
      backend/app/schemas/settings.py
  24. 53 11
      backend/app/services/archive.py
  25. 30 0
      backend/app/services/bambu_cloud.py
  26. 225 30
      backend/app/services/bambu_ftp.py
  27. 119 11
      backend/app/services/bambu_mqtt.py
  28. 7 6
      backend/app/services/discovery.py
  29. 382 0
      backend/app/services/firmware_check.py
  30. 378 0
      backend/app/services/firmware_update.py
  31. 42 9
      backend/app/services/print_scheduler.py
  32. 24 2
      backend/app/services/printer_manager.py
  33. 67 2
      backend/app/services/spoolman.py
  34. 15 1
      backend/app/services/telemetry.py
  35. 21 0
      backend/tests/integration/test_print_queue_api.py
  36. 82 0
      backend/tests/integration/test_printers_api.py
  37. 11 1
      backend/tests/unit/services/test_printer_manager.py
  38. 83 0
      bambuddy-issue-notes.txt
  39. 2 2
      frontend/public/sw.js
  40. 20 21
      frontend/src/App.tsx
  41. 5 0
      frontend/src/__tests__/pages/SystemInfoPage.test.tsx
  42. 134 2
      frontend/src/api/client.ts
  43. 27 9
      frontend/src/components/AMSHistoryModal.tsx
  44. 345 3
      frontend/src/components/AddToQueueModal.tsx
  45. 187 0
      frontend/src/components/BatchProjectModal.tsx
  46. 28 8
      frontend/src/components/CalendarView.tsx
  47. 4 3
      frontend/src/components/Card.tsx
  48. 9 7
      frontend/src/components/Dashboard.tsx
  49. 360 3
      frontend/src/components/EditQueueItemModal.tsx
  50. 8 6
      frontend/src/components/FilamentTrends.tsx
  51. 48 2
      frontend/src/components/Layout.tsx
  52. 15 3
      frontend/src/components/NotificationLogViewer.tsx
  53. 3 2
      frontend/src/components/NotificationProviderCard.tsx
  54. 106 60
      frontend/src/components/PrintCalendar.tsx
  55. 3 1
      frontend/src/components/PrinterQueueWidget.tsx
  56. 217 36
      frontend/src/components/ReprintModal.tsx
  57. 48 0
      frontend/src/components/icons/ChamberLight.tsx
  58. 2 4
      frontend/src/hooks/useWebSocket.ts
  59. 5 4
      frontend/src/i18n/locales/en.ts
  60. 814 154
      frontend/src/pages/ArchivesPage.tsx
  61. 46 42
      frontend/src/pages/CameraPage.tsx
  62. 127 2
      frontend/src/pages/MaintenancePage.tsx
  63. 376 79
      frontend/src/pages/PrintersPage.tsx
  64. 3 1
      frontend/src/pages/ProfilesPage.tsx
  65. 6 5
      frontend/src/pages/ProjectDetailPage.tsx
  66. 14 2
      frontend/src/pages/ProjectsPage.tsx
  67. 18 4
      frontend/src/pages/QueuePage.tsx
  68. 112 2
      frontend/src/pages/SettingsPage.tsx
  69. 187 73
      frontend/src/pages/StatsPage.tsx
  70. 176 3
      frontend/src/pages/SystemInfoPage.tsx
  71. 107 0
      frontend/src/utils/colors.ts
  72. 143 0
      frontend/src/utils/date.ts
  73. 3 0
      requirements.txt
  74. 0 0
      static/assets/index-BaxJ1N11.js
  75. 0 0
      static/assets/index-DSlUhPr3.css
  76. 0 0
      static/assets/index-Ds1sabci.css
  77. 0 0
      static/assets/index-rSeEpV8h.js
  78. 2 2
      static/index.html
  79. 2 2
      static/sw.js
  80. 5 0
      update_website_wiki.sh

+ 3 - 0
.gitignore

@@ -32,6 +32,9 @@ npm-debug.log*
 # Archive files (user data)
 archive/
 
+# Firmware cache (downloaded firmware files)
+firmware/
+
 # Virtual printer (auto-generated certs and uploads)
 virtual_printer/
 

+ 110 - 0
CHANGELOG.md

@@ -2,6 +2,116 @@
 
 All notable changes to Bambuddy will be documented in this file.
 
+## [0.1.6b10] - 2026-01-11
+
+### Added
+- **AMS color mapping** - Manually select which AMS slot to use for each filament:
+  - ReprintModal: Click dropdown to override auto-matched slots
+  - AddToQueueModal: Collapsible filament mapping section with slot selection
+  - Mapping stored with queued prints and used when print starts
+  - Blue ring indicator shows manually selected slots vs auto-matched
+  - Status indicators: green (match), yellow (type only), orange (not found)
+  - Re-read button to refresh AMS status if spools were swapped
+  - Color names shown in dropdowns and tooltips (decoded from Bambu filament codes or derived from hex)
+
+## [0.1.6b9] - 2026-01-09
+
+### Added
+- **Chamber light control** - Toggle the printer's chamber LED from the UI:
+  - Light button next to camera button at bottom of printer card
+  - Custom icon with on/off states (yellow when lit)
+  - Works on all Bambu Lab printers (X1, P1, A1 series, H2D)
+  - H2D dual chamber lights controlled together
+- **Archive list view improvements** - Full feature parity with card view:
+  - Edit and delete buttons inline with each row
+  - Three-dot menu button for context menu access
+  - All context menu actions (re-print, compare, add to project, etc.)
+- **Archive object count** - Shows number of printable objects on archive cards:
+  - Displays in stats grid (e.g., "3 objs")
+  - Extracted from 3MF metadata automatically
+- **Cross-view archive highlight** - Click an archive in calendar or project view to highlight it:
+  - Switches to card/list view and scrolls to the archive
+  - Yellow border highlight for 5 seconds
+  - Works in card, list, and calendar views
+- **Context menu visual indicator** - Three-dot button on cards and list items:
+  - Shows on hover (desktop) or always visible (mobile)
+  - Provides quick access to context menu actions
+  - Positioned on left side for easy access
+- **Spoolman location clearing** - When spools are removed from AMS, their location field is now cleared in Spoolman:
+  - Previously, location persisted even after spool removal
+  - Now correctly clears "Printer Name - AMS X Slot Y" when spool is no longer present
+- **FTP retry for unreliable WiFi** - Configurable retry logic for all FTP operations:
+  - Enable/disable retry in Settings > General > FTP Retry
+  - Configure retry count (1-10 attempts) and delay (1-30 seconds)
+  - Configurable connection timeout (10-120 seconds, default 30s)
+  - Applies to: 3MF archiving, print uploads, timelapse downloads, firmware updates
+  - Helps P1S, X1C, and other printers with weak WiFi connections
+- **A1/A1 Mini FTP fix** - Resolved FTP upload failures on A1 series printers:
+  - A1 printers have issues with SSL on the FTP data channel
+  - Automatic detection skips data channel SSL for A1 and A1 Mini models
+  - Control channel remains encrypted via implicit FTPS (port 990)
+  - Fixes "read operation timed out" errors during file uploads
+- **Bulk project assignment** - Assign multiple archives to a project at once:
+  - New "Project" button in multi-select toolbar (next to Tags)
+  - Select a project to assign all selected archives
+  - "Remove from project" option to clear assignments
+  - Updates Projects page and Project Detail page instantly
+
+### Fixed
+- **Time format setting not applied** - Fixed 24-hour time format not being respected across the UI:
+  - ETA display on printer cards now uses configured time format
+  - Archive cards and timelapse file lists respect the setting
+  - AMS history charts use the configured format
+  - Project timeline, queue page, notification logs, and system info all updated
+  - Settings > General > Time Format now works consistently everywhere
+- **QR code endpoint** - Fixed 500 error on archive QR code generation:
+  - Added `qrcode[pil]` to requirements.txt
+  - Improved error handling for missing dependencies
+  - Fixed PIL Image resizing method
+
+## [0.1.6b8] - 2026-01-08
+
+### Added
+- **Reprint modal print options** - Configure print settings when reprinting from archive:
+  - Bed leveling toggle (default: enabled)
+  - Flow calibration toggle (default: disabled)
+  - Vibration calibration toggle (default: enabled)
+  - First layer inspection toggle (default: disabled)
+  - Timelapse recording toggle (default: disabled)
+  - Collapsible "Print Options" section with toggle switches
+  - Settings sent to printer match Bambu Studio's format exactly
+
+### Fixed
+- **AMS mapping for multi-color reprints** - Fixed filament slot mapping for multi-color prints:
+  - AMS mapping now matches Bambu Studio's exact format with `ams_mapping2` detailed structure
+  - Correct handling of 3MF filament slot IDs (1-indexed to 0-indexed conversion)
+  - Unused filament slots properly filled with `-1` placeholder
+  - Prevents duplicate tray assignment when multiple filaments match the same type
+  - Resolves "stuck at filament change" issue on X1C and other multi-AMS printers
+- **Print command format** - Updated MQTT print command to match Bambu Studio exactly:
+  - Added `auto_bed_leveling`, `cfg`, `extrude_cali_flag`, `extrude_cali_manual_mode`, `nozzle_offset_cali` fields
+  - Uses American spelling `bed_leveling` (not British `bed_levelling`)
+  - Proper `sequence_id` format matching official apps
+
+## [0.1.6b7] - 2026-01-04
+
+### Added
+- **Support bundle for issue reporting** - Collect debug logs and system info for troubleshooting:
+  - Toggle debug logging from System Information page
+  - Debug logging indicator banner shows across all pages with live timer
+  - Download support bundle as ZIP file with sanitized logs and system info
+  - Privacy-focused: filters sensitive data (passwords, tokens, emails, IPs)
+  - Clear explanation of what data is/isn't collected
+- **Firmware update helper** - Check and upload firmware updates for LAN-only printers:
+  - Automatic firmware update checking against Bambu Lab's servers
+  - Orange "Update" badge on printer cards when updates are available
+  - Click badge to open firmware update modal with version info and release notes
+  - One-click firmware upload to printer's SD card via FTP
+  - Real-time upload progress tracking with actual bytes transferred
+  - Step-by-step instructions for triggering update from printer screen
+  - Supports all Bambu Lab printer models (X1C, X1, X1E, P1S, P1P, P2S, A1, A1 Mini, H2D, H2C, H2S)
+  - Firmware files cached locally for faster re-uploads
+
 ## [0.1.6b6] - 2026-01-04
 
 ### Added

+ 7 - 4
README.md

@@ -49,14 +49,14 @@
 - Duplicate detection & full-text search
 - Photo attachments & failure analysis
 - Timelapse editor (trim, speed, music)
-- Re-print to any connected printer with AMS filament preview
+- Re-print to any connected printer with AMS mapping (auto-match or manual slot selection)
 - Archive comparison (side-by-side diff)
 
 ### 📊 Monitoring & Control
 - Real-time printer status via WebSocket
 - Live camera streaming (MJPEG) & snapshots
 - Fan status monitoring (part cooling, auxiliary, chamber)
-- Printer control (stop, pause, resume)
+- Printer control (stop, pause, resume, chamber light)
 - Resizable printer cards (S/M/L/XL)
 - Skip objects during print
 - AMS slot RFID re-read
@@ -80,7 +80,7 @@
 - Track progress with target counts
 - Quantity tracking for batch prints
 - Color-coded project badges
-- Assign archives via context menu
+- Bulk assign archives via multi-select toolbar
 
 </td>
 <td width="50%" valign="top">
@@ -108,11 +108,14 @@
 - SSDP discovery (appears in slicer automatically)
 - Secure TLS/MQTT communication
 
-### 🛠️ Maintenance
+### 🛠️ Maintenance & Support
 - Maintenance scheduling & tracking
 - Interval reminders (hours/days)
 - Print time accuracy stats
 - File manager for printer storage
+- Firmware update helper (LAN-only printers)
+- Debug logging toggle with live indicator
+- Support bundle generator (privacy-filtered)
 
 </td>
 </tr>

+ 128 - 20
backend/app/api/routes/archives.py

@@ -12,7 +12,7 @@ from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
-from backend.app.schemas.archive import ArchiveResponse, ArchiveStats, ArchiveUpdate
+from backend.app.schemas.archive import ArchiveResponse, ArchiveStats, ArchiveUpdate, ReprintRequest
 from backend.app.services.archive import ArchiveService
 
 logger = logging.getLogger(__name__)
@@ -909,7 +909,12 @@ async def scan_timelapse(
 ):
     """Scan printer for timelapse matching this archive and attach it."""
     from backend.app.models.printer import Printer
-    from backend.app.services.bambu_ftp import download_file_bytes_async, list_files_async
+    from backend.app.services.bambu_ftp import (
+        download_file_bytes_async,
+        get_ftp_retry_settings,
+        list_files_async,
+        with_ftp_retry,
+    )
 
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
@@ -1081,7 +1086,30 @@ async def scan_timelapse(
 
     # Download the timelapse - use the full path from the file listing
     remote_path = matching_file.get("path") or f"/timelapse/{matching_file['name']}"
-    timelapse_data = await download_file_bytes_async(printer.ip_address, printer.access_code, remote_path)
+
+    # Get FTP retry settings
+    ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
+
+    if ftp_retry_enabled:
+        timelapse_data = await with_ftp_retry(
+            download_file_bytes_async,
+            printer.ip_address,
+            printer.access_code,
+            remote_path,
+            socket_timeout=ftp_timeout,
+            printer_model=printer.model,
+            max_retries=ftp_retry_count,
+            retry_delay=ftp_retry_delay,
+            operation_name=f"Download timelapse {matching_file['name']}",
+        )
+    else:
+        timelapse_data = await download_file_bytes_async(
+            printer.ip_address,
+            printer.access_code,
+            remote_path,
+            socket_timeout=ftp_timeout,
+            printer_model=printer.model,
+        )
 
     if not timelapse_data:
         raise HTTPException(500, "Failed to download timelapse")
@@ -1107,7 +1135,12 @@ async def select_timelapse(
 ):
     """Manually select a timelapse from the printer to attach."""
     from backend.app.models.printer import Printer
-    from backend.app.services.bambu_ftp import download_file_bytes_async, list_files_async
+    from backend.app.services.bambu_ftp import (
+        download_file_bytes_async,
+        get_ftp_retry_settings,
+        list_files_async,
+        with_ftp_retry,
+    )
 
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
@@ -1141,7 +1174,29 @@ async def select_timelapse(
         raise HTTPException(404, f"Timelapse '{filename}' not found on printer")
 
     # Download and attach
-    timelapse_data = await download_file_bytes_async(printer.ip_address, printer.access_code, remote_path)
+    ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
+
+    if ftp_retry_enabled:
+        timelapse_data = await with_ftp_retry(
+            download_file_bytes_async,
+            printer.ip_address,
+            printer.access_code,
+            remote_path,
+            socket_timeout=ftp_timeout,
+            printer_model=printer.model,
+            max_retries=ftp_retry_count,
+            retry_delay=ftp_retry_delay,
+            operation_name=f"Download timelapse {filename}",
+        )
+    else:
+        timelapse_data = await download_file_bytes_async(
+            printer.ip_address,
+            printer.access_code,
+            remote_path,
+            socket_timeout=ftp_timeout,
+            printer_model=printer.model,
+        )
+
     if not timelapse_data:
         raise HTTPException(500, "Failed to download timelapse")
 
@@ -1464,16 +1519,20 @@ async def get_qrcode(
     db: AsyncSession = Depends(get_db),
 ):
     """Generate a QR code that links to this archive."""
-    import qrcode
+    try:
+        import qrcode
+        from PIL import Image as PILImage
+    except ImportError:
+        raise HTTPException(500, "QR code generation not available - qrcode package not installed")
 
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     archive = result.scalar_one_or_none()
     if not archive:
         raise HTTPException(404, "Archive not found")
 
-    # Build URL to archive detail page
+    # Build URL to archive download
     base_url = str(request.base_url).rstrip("/")
-    archive_url = f"{base_url}/archives?id={archive_id}"
+    archive_url = f"{base_url}/api/v1/archives/{archive_id}/download"
 
     # Generate QR code
     qr = qrcode.QRCode(
@@ -1487,13 +1546,16 @@ async def get_qrcode(
 
     img = qr.make_image(fill_color="black", back_color="white")
 
+    # Convert to PIL Image for resizing
+    pil_img = img.get_image()
+
     # Resize if needed
     if size != 200:
-        img = img.resize((size, size))
+        pil_img = pil_img.resize((size, size), PILImage.Resampling.LANCZOS)
 
     # Convert to bytes
     buffer = io.BytesIO()
-    img.save(buffer, format="PNG")
+    pil_img.save(buffer, format="PNG")
     buffer.seek(0)
 
     return Response(
@@ -1974,14 +2036,23 @@ async def get_filament_requirements(
 async def reprint_archive(
     archive_id: int,
     printer_id: int,
+    body: ReprintRequest | None = None,
     db: AsyncSession = Depends(get_db),
 ):
     """Send an archived 3MF file to a printer and start printing."""
     from backend.app.main import register_expected_print
     from backend.app.models.printer import Printer
-    from backend.app.services.bambu_ftp import upload_file_async
+    from backend.app.services.bambu_ftp import (
+        get_ftp_retry_settings,
+        upload_file_async,
+        with_ftp_retry,
+    )
     from backend.app.services.printer_manager import printer_manager
 
+    # Use defaults if no body provided
+    if body is None:
+        body = ReprintRequest()
+
     # Get archive
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
@@ -2016,19 +2087,40 @@ async def reprint_archive(
     remote_filename = f"{base_name}.3mf"
     remote_path = f"/{remote_filename}"
 
+    # Get FTP retry settings
+    ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
+
     # Delete existing file if present (avoids 553 error)
     await delete_file_async(
         printer.ip_address,
         printer.access_code,
         remote_path,
+        socket_timeout=ftp_timeout,
+        printer_model=printer.model,
     )
 
-    uploaded = await upload_file_async(
-        printer.ip_address,
-        printer.access_code,
-        file_path,
-        remote_path,
-    )
+    if ftp_retry_enabled:
+        uploaded = await with_ftp_retry(
+            upload_file_async,
+            printer.ip_address,
+            printer.access_code,
+            file_path,
+            remote_path,
+            socket_timeout=ftp_timeout,
+            printer_model=printer.model,
+            max_retries=ftp_retry_count,
+            retry_delay=ftp_retry_delay,
+            operation_name=f"Upload for reprint to {printer.name}",
+        )
+    else:
+        uploaded = await upload_file_async(
+            printer.ip_address,
+            printer.access_code,
+            file_path,
+            remote_path,
+            socket_timeout=ftp_timeout,
+            printer_model=printer.model,
+        )
 
     if not uploaded:
         raise HTTPException(500, "Failed to upload file to printer")
@@ -2049,10 +2141,26 @@ async def reprint_archive(
     except Exception:
         pass  # Default to plate 1 if detection fails
 
-    logger.info(f"Reprint archive {archive_id}: using plate_id={plate_id}")
+    logger.info(
+        f"Reprint archive {archive_id}: plate_id={plate_id}, "
+        f"ams_mapping={body.ams_mapping}, bed_levelling={body.bed_levelling}, "
+        f"flow_cali={body.flow_cali}, vibration_cali={body.vibration_cali}, "
+        f"layer_inspect={body.layer_inspect}, timelapse={body.timelapse}"
+    )
 
-    # Start the print
-    started = printer_manager.start_print(printer_id, remote_filename, plate_id)
+    # Start the print with options
+    started = printer_manager.start_print(
+        printer_id,
+        remote_filename,
+        plate_id,
+        ams_mapping=body.ams_mapping,
+        timelapse=body.timelapse,
+        bed_levelling=body.bed_levelling,
+        flow_cali=body.flow_cali,
+        vibration_cali=body.vibration_cali,
+        layer_inspect=body.layer_inspect,
+        use_ams=body.use_ams,
+    )
 
     if not started:
         raise HTTPException(500, "Failed to start print")

+ 78 - 0
backend/app/api/routes/cloud.py

@@ -22,6 +22,8 @@ from backend.app.schemas.cloud import (
     CloudLoginResponse,
     CloudTokenRequest,
     CloudVerifyRequest,
+    FirmwareUpdateInfo,
+    FirmwareUpdatesResponse,
     SlicerSetting,
     SlicerSettingCreate,
     SlicerSettingDeleteResponse,
@@ -371,6 +373,82 @@ async def get_devices(db: AsyncSession = Depends(get_db)):
         raise HTTPException(status_code=500, detail=str(e))
 
 
+@router.get("/firmware-updates", response_model=FirmwareUpdatesResponse)
+async def get_firmware_updates(db: AsyncSession = Depends(get_db)):
+    """
+    Check for firmware updates for all bound devices.
+
+    Returns firmware version info for each device including:
+    - Current installed version
+    - Latest available version
+    - Whether an update is available
+    - Release notes for the latest version
+
+    Requires cloud authentication.
+    """
+    token, _ = await get_stored_token(db)
+    if not token:
+        raise HTTPException(status_code=401, detail="Not authenticated")
+
+    cloud = get_cloud_service()
+    cloud.set_token(token)
+
+    if not cloud.is_authenticated:
+        raise HTTPException(status_code=401, detail="Not authenticated")
+
+    try:
+        # First get list of bound devices
+        devices_data = await cloud.get_devices()
+        devices = devices_data.get("devices", [])
+
+        updates = []
+        updates_available = 0
+
+        # Check firmware for each device
+        for device in devices:
+            device_id = device.get("dev_id", "")
+            device_name = device.get("name", "Unknown")
+
+            try:
+                firmware_info = await cloud.get_firmware_version(device_id)
+                update_available = firmware_info.get("update_available", False)
+
+                if update_available:
+                    updates_available += 1
+
+                updates.append(
+                    FirmwareUpdateInfo(
+                        device_id=device_id,
+                        device_name=device_name,
+                        current_version=firmware_info.get("current_version"),
+                        latest_version=firmware_info.get("latest_version"),
+                        update_available=update_available,
+                        release_notes=firmware_info.get("release_notes"),
+                    )
+                )
+            except BambuCloudError as e:
+                logger.warning(f"Failed to get firmware info for {device_name}: {e}")
+                # Still include device but with unknown firmware status
+                updates.append(
+                    FirmwareUpdateInfo(
+                        device_id=device_id,
+                        device_name=device_name,
+                        current_version=None,
+                        latest_version=None,
+                        update_available=False,
+                        release_notes=None,
+                    )
+                )
+
+        return FirmwareUpdatesResponse(updates=updates, updates_available=updates_available)
+
+    except BambuCloudAuthError:
+        await clear_token(db)
+        raise HTTPException(status_code=401, detail="Authentication expired")
+    except BambuCloudError as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+
 @router.post("/settings")
 async def create_setting(request: SlicerSettingCreate, db: AsyncSession = Depends(get_db)):
     """

+ 298 - 0
backend/app/api/routes/firmware.py

@@ -0,0 +1,298 @@
+"""
+Firmware Update API Routes
+
+Check for firmware updates from Bambu Lab.
+Also provides endpoints for uploading firmware to printers via SD card.
+"""
+
+import logging
+
+from fastapi import APIRouter, Depends, HTTPException
+from pydantic import BaseModel, Field
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.database import get_db
+from backend.app.models.printer import Printer
+from backend.app.services.firmware_check import get_firmware_service
+from backend.app.services.firmware_update import (
+    FirmwareUploadStatus,
+    get_firmware_update_service,
+    get_upload_state,
+)
+from backend.app.services.printer_manager import printer_manager
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/firmware", tags=["firmware"])
+
+
+class FirmwareUpdateInfo(BaseModel):
+    """Firmware update information for a printer."""
+
+    printer_id: int
+    printer_name: str
+    model: str | None
+    current_version: str | None
+    latest_version: str | None
+    update_available: bool
+    download_url: str | None = None
+    release_notes: str | None = None
+
+
+class FirmwareUpdatesResponse(BaseModel):
+    """Response containing firmware updates for all printers."""
+
+    updates: list[FirmwareUpdateInfo] = Field(default_factory=list)
+    updates_available: int = Field(0, description="Number of printers with updates available")
+
+
+class LatestFirmwareInfo(BaseModel):
+    """Latest firmware version info for a model."""
+
+    model_key: str
+    version: str
+    download_url: str
+    release_notes: str | None = None
+
+
+@router.get("/updates", response_model=FirmwareUpdatesResponse)
+async def check_firmware_updates(
+    db: AsyncSession = Depends(get_db),
+):
+    """
+    Check for firmware updates for all connected printers.
+
+    Compares each printer's current firmware version against the latest
+    available version from Bambu Lab's official firmware download page.
+
+    Note: This does not require cloud authentication - it uses public
+    firmware information from bambulab.com.
+    """
+    firmware_service = get_firmware_service()
+
+    # Get all printers from database
+    result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
+    printers = result.scalars().all()
+
+    updates = []
+    updates_available = 0
+
+    for printer in printers:
+        # Get current firmware version from MQTT state
+        current_version = None
+        mqtt_client = printer_manager.get_client(printer.id)
+        if mqtt_client and mqtt_client.state:
+            current_version = mqtt_client.state.firmware_version
+
+        # Check for update
+        model = printer.model or "Unknown"
+        update_info = await firmware_service.check_for_update(model, current_version or "")
+
+        if update_info["update_available"]:
+            updates_available += 1
+
+        updates.append(
+            FirmwareUpdateInfo(
+                printer_id=printer.id,
+                printer_name=printer.name,
+                model=model,
+                current_version=current_version,
+                latest_version=update_info["latest_version"],
+                update_available=update_info["update_available"],
+                download_url=update_info["download_url"],
+                release_notes=update_info["release_notes"],
+            )
+        )
+
+    return FirmwareUpdatesResponse(updates=updates, updates_available=updates_available)
+
+
+@router.get("/updates/{printer_id}", response_model=FirmwareUpdateInfo)
+async def check_printer_firmware(
+    printer_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """
+    Check for firmware update for a specific printer.
+    """
+    firmware_service = get_firmware_service()
+
+    # Get printer from database
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+
+    if not printer:
+        raise HTTPException(status_code=404, detail="Printer not found")
+
+    # Get current firmware version from MQTT state
+    current_version = None
+    mqtt_client = printer_manager.get_client(printer.id)
+    if mqtt_client and mqtt_client.state:
+        current_version = mqtt_client.state.firmware_version
+
+    # Check for update
+    model = printer.model or "Unknown"
+    update_info = await firmware_service.check_for_update(model, current_version or "")
+
+    return FirmwareUpdateInfo(
+        printer_id=printer.id,
+        printer_name=printer.name,
+        model=model,
+        current_version=current_version,
+        latest_version=update_info["latest_version"],
+        update_available=update_info["update_available"],
+        download_url=update_info["download_url"],
+        release_notes=update_info["release_notes"],
+    )
+
+
+@router.get("/latest", response_model=list[LatestFirmwareInfo])
+async def get_all_latest_firmware():
+    """
+    Get the latest firmware versions for all Bambu Lab printer models.
+
+    This endpoint fetches the latest available firmware versions from
+    Bambu Lab's official firmware download page.
+    """
+    firmware_service = get_firmware_service()
+    versions = await firmware_service.get_all_latest_versions()
+
+    return [
+        LatestFirmwareInfo(
+            model_key=key,
+            version=info.version,
+            download_url=info.download_url,
+            release_notes=info.release_notes,
+        )
+        for key, info in versions.items()
+    ]
+
+
+# ============================================================================
+# Firmware Upload Endpoints (for LAN-only firmware updates)
+# ============================================================================
+
+
+class FirmwareUploadPrepareResponse(BaseModel):
+    """Response from firmware upload preparation check."""
+
+    can_proceed: bool
+    sd_card_present: bool
+    sd_card_free_space: int = Field(-1, description="Free space in bytes, -1 if unknown")
+    firmware_size: int = Field(0, description="Estimated firmware size in bytes")
+    space_sufficient: bool
+    update_available: bool
+    current_version: str | None = None
+    latest_version: str | None = None
+    firmware_filename: str | None = None
+    errors: list[str] = Field(default_factory=list)
+
+
+class FirmwareUploadStatusResponse(BaseModel):
+    """Response containing firmware upload status."""
+
+    status: str  # idle, preparing, downloading, uploading, complete, error
+    progress: int = Field(0, ge=0, le=100)
+    message: str = ""
+    error: str | None = None
+    firmware_filename: str | None = None
+    firmware_version: str | None = None
+
+
+class FirmwareUploadStartResponse(BaseModel):
+    """Response when starting a firmware upload."""
+
+    started: bool
+    message: str
+
+
+@router.get("/updates/{printer_id}/prepare", response_model=FirmwareUploadPrepareResponse)
+async def prepare_firmware_upload(
+    printer_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """
+    Check prerequisites for uploading firmware to a printer.
+
+    This performs pre-flight checks including:
+    - SD card presence
+    - Available storage space
+    - Update availability
+
+    Call this before starting a firmware upload to ensure the operation
+    can succeed.
+    """
+    update_service = get_firmware_update_service()
+    result = await update_service.prepare_update(printer_id, db)
+    return FirmwareUploadPrepareResponse(**result)
+
+
+@router.post("/updates/{printer_id}/upload", response_model=FirmwareUploadStartResponse)
+async def start_firmware_upload(
+    printer_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """
+    Start uploading firmware to a printer's SD card.
+
+    This initiates a background process that:
+    1. Downloads the firmware from Bambu Lab
+    2. Uploads it to the printer's SD card via FTP
+
+    Progress is broadcast via WebSocket with type "firmware_upload_progress".
+    Use GET /firmware/updates/{printer_id}/upload/status for polling fallback.
+
+    After upload completes, the user must trigger the update from the
+    printer's screen (Settings > Firmware).
+    """
+    # First check prerequisites
+    update_service = get_firmware_update_service()
+    prepare_result = await update_service.prepare_update(printer_id, db)
+
+    if not prepare_result["can_proceed"]:
+        errors = prepare_result.get("errors", ["Cannot proceed with firmware upload"])
+        raise HTTPException(
+            status_code=400,
+            detail="; ".join(errors),
+        )
+
+    # Start the upload
+    started = await update_service.start_upload(printer_id, db)
+
+    if not started:
+        state = get_upload_state(printer_id)
+        if state.status == FirmwareUploadStatus.DOWNLOADING:
+            return FirmwareUploadStartResponse(
+                started=False,
+                message="Firmware upload already in progress",
+            )
+        raise HTTPException(
+            status_code=500,
+            detail=state.error or "Failed to start firmware upload",
+        )
+
+    return FirmwareUploadStartResponse(
+        started=True,
+        message="Firmware upload started. Progress will be broadcast via WebSocket.",
+    )
+
+
+@router.get("/updates/{printer_id}/upload/status", response_model=FirmwareUploadStatusResponse)
+async def get_firmware_upload_status(printer_id: int):
+    """
+    Get the current status of a firmware upload operation.
+
+    This is a polling fallback for clients that don't use WebSocket.
+    For real-time updates, connect to WebSocket and listen for
+    "firmware_upload_progress" messages.
+    """
+    state = get_upload_state(printer_id)
+    return FirmwareUploadStatusResponse(
+        status=state.status.value,
+        progress=state.progress,
+        message=state.message,
+        error=state.error,
+        firmware_filename=state.firmware_filename,
+        firmware_version=state.firmware_version,
+    )

+ 15 - 1
backend/app/api/routes/maintenance.py

@@ -294,9 +294,11 @@ async def _get_printer_maintenance_internal(
                 id=item_id,
                 printer_id=printer_id,
                 printer_name=printer.name,
+                printer_model=printer.model,
                 maintenance_type_id=maint_type.id,
                 maintenance_type_name=maint_type.name,
                 maintenance_type_icon=maint_type.icon,
+                maintenance_type_wiki_url=getattr(maint_type, "wiki_url", None),
                 enabled=enabled,
                 interval_hours=interval,
                 interval_type=interval_type,
@@ -317,6 +319,7 @@ async def _get_printer_maintenance_internal(
     return PrinterMaintenanceOverview(
         printer_id=printer_id,
         printer_name=printer.name,
+        printer_model=printer.model,
         total_print_hours=total_hours,
         maintenance_items=maintenance_items,
         due_count=due_count,
@@ -417,7 +420,16 @@ async def assign_maintenance_type(
     )
     db.add(item)
     await db.commit()
-    await db.refresh(item)
+
+    # Re-fetch with relationship loaded for response serialization
+    from sqlalchemy.orm import selectinload
+
+    result = await db.execute(
+        select(PrinterMaintenance)
+        .options(selectinload(PrinterMaintenance.maintenance_type))
+        .where(PrinterMaintenance.id == item.id)
+    )
+    item = result.scalar_one()
 
     return item
 
@@ -494,9 +506,11 @@ async def perform_maintenance(
         id=item.id,
         printer_id=item.printer_id,
         printer_name=printer.name,
+        printer_model=printer.model,
         maintenance_type_id=item.maintenance_type_id,
         maintenance_type_name=item.maintenance_type.name,
         maintenance_type_icon=item.maintenance_type.icon,
+        maintenance_type_wiki_url=getattr(item.maintenance_type, "wiki_url", None),
         enabled=item.enabled,
         interval_hours=interval,
         interval_type=interval_type,

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

@@ -1,5 +1,6 @@
 """API routes for print queue management."""
 
+import json
 import logging
 from datetime import datetime
 
@@ -26,7 +27,32 @@ router = APIRouter(prefix="/queue", tags=["queue"])
 
 def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
     """Add nested archive/printer info to response."""
-    response = PrintQueueItemResponse.model_validate(item)
+    # Parse ams_mapping from JSON string BEFORE model_validate
+    ams_mapping_parsed = None
+    if item.ams_mapping:
+        try:
+            ams_mapping_parsed = json.loads(item.ams_mapping)
+        except json.JSONDecodeError:
+            ams_mapping_parsed = None
+
+    # Create response with parsed ams_mapping
+    item_dict = {
+        "id": item.id,
+        "printer_id": item.printer_id,
+        "archive_id": item.archive_id,
+        "position": item.position,
+        "scheduled_time": item.scheduled_time,
+        "require_previous_success": item.require_previous_success,
+        "auto_off_after": item.auto_off_after,
+        "manual_start": item.manual_start,
+        "ams_mapping": ams_mapping_parsed,
+        "status": item.status,
+        "started_at": item.started_at,
+        "completed_at": item.completed_at,
+        "error_message": item.error_message,
+        "created_at": item.created_at,
+    }
+    response = PrintQueueItemResponse(**item_dict)
     if item.archive:
         response.archive_name = item.archive.print_name or item.archive.filename
         response.archive_thumbnail = item.archive.thumbnail_path
@@ -90,6 +116,7 @@ async def add_to_queue(
         require_previous_success=data.require_previous_success,
         auto_off_after=data.auto_off_after,
         manual_start=data.manual_start,
+        ams_mapping=json.dumps(data.ams_mapping) if data.ams_mapping else None,
         position=max_pos + 1,
         status="pending",
     )
@@ -141,6 +168,10 @@ async def update_queue_item(
         if not result.scalar_one_or_none():
             raise HTTPException(400, "Printer not found")
 
+    # Serialize ams_mapping to JSON for TEXT column storage
+    if "ams_mapping" in update_data:
+        update_data["ams_mapping"] = json.dumps(update_data["ams_mapping"]) if update_data["ams_mapping"] else None
+
     for field, value in update_data.items():
         setattr(item, field, value)
 

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

@@ -3,7 +3,7 @@ import logging
 import re
 import zipfile
 
-from fastapi import APIRouter, Depends, HTTPException
+from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi.responses import Response
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
@@ -117,7 +117,10 @@ async def delete_printer(
         delete_archives: If True (default), delete all print archives for this printer.
                         If False, keep archives but remove their printer association.
     """
+    from sqlalchemy import delete as sql_delete
+
     from backend.app.models.archive import PrintArchive
+    from backend.app.models.maintenance import MaintenanceHistory, PrinterMaintenance
 
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
@@ -132,6 +135,19 @@ async def delete_printer(
 
         await db.execute(update(PrintArchive).where(PrintArchive.printer_id == printer_id).values(printer_id=None))
 
+    # Delete maintenance history and items for this printer
+    # (SQLite doesn't enforce FK cascades, so do it explicitly)
+    maintenance_ids = (
+        (await db.execute(select(PrinterMaintenance.id).where(PrinterMaintenance.printer_id == printer_id)))
+        .scalars()
+        .all()
+    )
+    if maintenance_ids:
+        await db.execute(
+            sql_delete(MaintenanceHistory).where(MaintenanceHistory.printer_maintenance_id.in_(maintenance_ids))
+        )
+        await db.execute(sql_delete(PrinterMaintenance).where(PrinterMaintenance.printer_id == printer_id))
+
     await db.delete(printer)
     await db.commit()
 
@@ -362,6 +378,11 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
         mc_print_sub_stage=state.mc_print_sub_stage,
         last_ams_update=state.last_ams_update,
         printable_objects_count=len(state.printable_objects),
+        cooling_fan_speed=state.cooling_fan_speed,
+        big_fan1_speed=state.big_fan1_speed,
+        big_fan2_speed=state.big_fan2_speed,
+        heatbreak_fan_speed=state.heatbreak_fan_speed,
+        firmware_version=state.firmware_version,
     )
 
 
@@ -1133,6 +1154,29 @@ async def resume_print(printer_id: int, db: AsyncSession = Depends(get_db)):
     return {"success": True, "message": "Print resume command sent"}
 
 
+@router.post("/{printer_id}/chamber-light")
+async def set_chamber_light(
+    printer_id: int,
+    on: bool = Query(..., description="True to turn on, False to turn off"),
+    db: AsyncSession = Depends(get_db),
+):
+    """Turn the chamber light on or off."""
+    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")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    success = client.set_chamber_light(on)
+    if not success:
+        raise HTTPException(500, "Failed to control chamber light")
+
+    return {"success": True, "message": f"Chamber light {'on' if on else 'off'}"}
+
+
 @router.get("/{printer_id}/print/objects")
 async def get_printable_objects(
     printer_id: int,

+ 8 - 1
backend/app/api/routes/settings.py

@@ -72,11 +72,18 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
                 "check_updates",
                 "telemetry_enabled",
                 "virtual_printer_enabled",
+                "ftp_retry_enabled",
             ]:
                 settings_dict[setting.key] = setting.value.lower() == "true"
             elif setting.key in ["default_filament_cost", "energy_cost_per_kwh", "ams_temp_good", "ams_temp_fair"]:
                 settings_dict[setting.key] = float(setting.value)
-            elif setting.key in ["ams_humidity_good", "ams_humidity_fair", "ams_history_retention_days"]:
+            elif setting.key in [
+                "ams_humidity_good",
+                "ams_humidity_fair",
+                "ams_history_retention_days",
+                "ftp_retry_count",
+                "ftp_retry_delay",
+            ]:
                 settings_dict[setting.key] = int(setting.value)
             elif setting.key == "default_printer_id":
                 # Handle nullable integer

+ 30 - 0
backend/app/api/routes/spoolman.py

@@ -169,6 +169,8 @@ async def sync_printer_ams(
     synced = 0
     skipped: list[SkippedSpool] = []
     errors = []
+    # Track tray UUIDs currently in the AMS (for clearing removed spools)
+    current_tray_uuids: set[str] = set()
 
     # Handle different AMS data structures
     # Traditional AMS: list of {"id": N, "tray": [...]} dicts
@@ -222,6 +224,9 @@ async def sync_printer_ams(
                 )
                 continue
 
+            # Track this tray UUID as currently present in the AMS
+            current_tray_uuids.add(tray.tray_uuid.upper())
+
             try:
                 sync_result = await client.sync_ams_tray(tray, printer.name)
                 if sync_result:
@@ -235,6 +240,14 @@ async def sync_printer_ams(
                 logger.error(error_msg)
                 errors.append(error_msg)
 
+    # Clear location for spools that were removed from this printer's AMS
+    try:
+        cleared = await client.clear_location_for_removed_spools(printer.name, current_tray_uuids)
+        if cleared > 0:
+            logger.info(f"Cleared location for {cleared} spools removed from {printer.name}")
+    except Exception as e:
+        logger.error(f"Error clearing locations for removed spools: {e}")
+
     return SyncResult(
         success=len(errors) == 0,
         synced_count=synced,
@@ -269,6 +282,8 @@ async def sync_all_printers(db: AsyncSession = Depends(get_db)):
     total_synced = 0
     all_skipped: list[SkippedSpool] = []
     all_errors = []
+    # Track tray UUIDs per printer (for clearing removed spools)
+    printer_tray_uuids: dict[str, set[str]] = {}
 
     for printer in printers:
         state = printer_manager.get_status(printer.id)
@@ -279,6 +294,9 @@ async def sync_all_printers(db: AsyncSession = Depends(get_db)):
         if not ams_data:
             continue
 
+        # Initialize tray UUID set for this printer
+        printer_tray_uuids[printer.name] = set()
+
         # Handle different AMS data structures
         # Traditional AMS: list of {"id": N, "tray": [...]} dicts
         # H2D/newer printers: dict with different structure
@@ -330,6 +348,9 @@ async def sync_all_printers(db: AsyncSession = Depends(get_db)):
                     )
                     continue
 
+                # Track this tray UUID as currently present in the AMS
+                printer_tray_uuids[printer.name].add(tray.tray_uuid.upper())
+
                 try:
                     sync_result = await client.sync_ams_tray(tray, printer.name)
                     if sync_result:
@@ -337,6 +358,15 @@ async def sync_all_printers(db: AsyncSession = Depends(get_db)):
                 except Exception as e:
                     all_errors.append(f"{printer.name} AMS {ams_id}:{tray.tray_id}: {e}")
 
+    # Clear location for spools that were removed from each printer's AMS
+    for printer_name, current_tray_uuids in printer_tray_uuids.items():
+        try:
+            cleared = await client.clear_location_for_removed_spools(printer_name, current_tray_uuids)
+            if cleared > 0:
+                logger.info(f"Cleared location for {cleared} spools removed from {printer_name}")
+        except Exception as e:
+            logger.error(f"Error clearing locations for {printer_name}: {e}")
+
     return SyncResult(
         success=len(all_errors) == 0,
         synced_count=total_synced,

+ 347 - 0
backend/app/api/routes/support.py

@@ -0,0 +1,347 @@
+"""Support endpoints for debug logging and support bundle generation."""
+
+import io
+import json
+import logging
+import os
+import platform
+import zipfile
+from datetime import datetime
+
+from fastapi import APIRouter, HTTPException
+from fastapi.responses import StreamingResponse
+from pydantic import BaseModel
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.config import APP_VERSION, settings
+from backend.app.core.database import async_session
+from backend.app.models.archive import PrintArchive
+from backend.app.models.filament import Filament
+from backend.app.models.printer import Printer
+from backend.app.models.project import Project
+from backend.app.models.settings import Settings
+from backend.app.models.smart_plug import SmartPlug
+
+router = APIRouter(prefix="/support", tags=["support"])
+logger = logging.getLogger(__name__)
+
+# In-memory state for debug logging (persisted to settings DB)
+_debug_logging_enabled = False
+_debug_logging_enabled_at: datetime | None = None
+
+
+class DebugLoggingState(BaseModel):
+    enabled: bool
+    enabled_at: str | None = None
+    duration_seconds: int | None = None
+
+
+class DebugLoggingToggle(BaseModel):
+    enabled: bool
+
+
+async def _get_debug_setting(db: AsyncSession) -> tuple[bool, datetime | None]:
+    """Get debug logging state from database."""
+    result = await db.execute(select(Settings).where(Settings.key == "debug_logging_enabled"))
+    enabled_setting = result.scalar_one_or_none()
+
+    result = await db.execute(select(Settings).where(Settings.key == "debug_logging_enabled_at"))
+    enabled_at_setting = result.scalar_one_or_none()
+
+    enabled = enabled_setting.value.lower() == "true" if enabled_setting else False
+    enabled_at = None
+    if enabled_at_setting and enabled_at_setting.value:
+        try:
+            enabled_at = datetime.fromisoformat(enabled_at_setting.value)
+        except ValueError:
+            pass
+
+    return enabled, enabled_at
+
+
+async def _set_debug_setting(db: AsyncSession, enabled: bool) -> datetime | None:
+    """Set debug logging state in database."""
+    # Update or create enabled setting
+    result = await db.execute(select(Settings).where(Settings.key == "debug_logging_enabled"))
+    setting = result.scalar_one_or_none()
+    if setting:
+        setting.value = str(enabled).lower()
+    else:
+        db.add(Settings(key="debug_logging_enabled", value=str(enabled).lower()))
+
+    # Update enabled_at timestamp
+    enabled_at = datetime.now() if enabled else None
+    result = await db.execute(select(Settings).where(Settings.key == "debug_logging_enabled_at"))
+    at_setting = result.scalar_one_or_none()
+    if at_setting:
+        at_setting.value = enabled_at.isoformat() if enabled_at else ""
+    else:
+        db.add(Settings(key="debug_logging_enabled_at", value=enabled_at.isoformat() if enabled_at else ""))
+
+    await db.commit()
+    return enabled_at
+
+
+def _apply_log_level(debug: bool):
+    """Apply log level change to root logger."""
+    root_logger = logging.getLogger()
+    new_level = logging.DEBUG if debug else logging.INFO
+
+    root_logger.setLevel(new_level)
+    for handler in root_logger.handlers:
+        handler.setLevel(new_level)
+
+    # Also adjust third-party loggers
+    if debug:
+        logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)
+        logging.getLogger("httpcore").setLevel(logging.DEBUG)
+        logging.getLogger("httpx").setLevel(logging.DEBUG)
+    else:
+        logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
+        logging.getLogger("httpcore").setLevel(logging.WARNING)
+        logging.getLogger("httpx").setLevel(logging.WARNING)
+
+    logger.info(f"Log level changed to {'DEBUG' if debug else 'INFO'}")
+
+
+@router.get("/debug-logging", response_model=DebugLoggingState)
+async def get_debug_logging_state():
+    """Get current debug logging state."""
+    global _debug_logging_enabled, _debug_logging_enabled_at
+
+    async with async_session() as db:
+        enabled, enabled_at = await _get_debug_setting(db)
+        _debug_logging_enabled = enabled
+        _debug_logging_enabled_at = enabled_at
+
+    duration = None
+    if enabled and enabled_at:
+        duration = int((datetime.now() - enabled_at).total_seconds())
+
+    return DebugLoggingState(
+        enabled=enabled,
+        enabled_at=enabled_at.isoformat() if enabled_at else None,
+        duration_seconds=duration,
+    )
+
+
+@router.post("/debug-logging", response_model=DebugLoggingState)
+async def toggle_debug_logging(toggle: DebugLoggingToggle):
+    """Enable or disable debug logging."""
+    global _debug_logging_enabled, _debug_logging_enabled_at
+
+    async with async_session() as db:
+        enabled_at = await _set_debug_setting(db, toggle.enabled)
+        _debug_logging_enabled = toggle.enabled
+        _debug_logging_enabled_at = enabled_at
+
+    _apply_log_level(toggle.enabled)
+
+    duration = None
+    if toggle.enabled and enabled_at:
+        duration = int((datetime.now() - enabled_at).total_seconds())
+
+    return DebugLoggingState(
+        enabled=toggle.enabled,
+        enabled_at=enabled_at.isoformat() if enabled_at else None,
+        duration_seconds=duration,
+    )
+
+
+def _sanitize_path(path: str) -> str:
+    """Remove username from paths for privacy."""
+    import re
+
+    # Replace /home/username/ or /Users/username/ with /home/[user]/
+    path = re.sub(r"/home/[^/]+/", "/home/[user]/", path)
+    path = re.sub(r"/Users/[^/]+/", "/Users/[user]/", path)
+    # Replace /opt/username/ patterns
+    path = re.sub(r"/opt/[^/]+/", "/opt/[user]/", path)
+    return path
+
+
+async def _collect_support_info() -> dict:
+    """Collect all support information."""
+    info = {
+        "generated_at": datetime.now().isoformat(),
+        "app": {
+            "version": APP_VERSION,
+            "debug_mode": settings.debug,
+        },
+        "system": {
+            "platform": platform.system(),
+            "platform_release": platform.release(),
+            "platform_version": platform.version(),
+            "architecture": platform.machine(),
+            "python_version": platform.python_version(),
+        },
+        "environment": {
+            "docker": os.path.exists("/.dockerenv"),
+            "data_dir": _sanitize_path(str(settings.base_dir)),
+            "log_dir": _sanitize_path(str(settings.log_dir)),
+        },
+        "database": {},
+        "printers": [],
+        "settings": {},
+    }
+
+    async with async_session() as db:
+        # Database stats
+        result = await db.execute(select(func.count(PrintArchive.id)))
+        info["database"]["archives_total"] = result.scalar() or 0
+
+        result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed"))
+        info["database"]["archives_completed"] = result.scalar() or 0
+
+        result = await db.execute(select(func.count(Printer.id)))
+        info["database"]["printers_total"] = result.scalar() or 0
+
+        result = await db.execute(select(func.count(Filament.id)))
+        info["database"]["filaments_total"] = result.scalar() or 0
+
+        result = await db.execute(select(func.count(Project.id)))
+        info["database"]["projects_total"] = result.scalar() or 0
+
+        result = await db.execute(select(func.count(SmartPlug.id)))
+        info["database"]["smart_plugs_total"] = result.scalar() or 0
+
+        # Printer info (anonymized - just models and connection status)
+        result = await db.execute(select(Printer))
+        printers = result.scalars().all()
+        for i, printer in enumerate(printers):
+            info["printers"].append(
+                {
+                    "index": i + 1,
+                    "model": printer.model or "Unknown",
+                    "nozzle_count": printer.nozzle_count,
+                }
+            )
+
+        # Non-sensitive settings
+        result = await db.execute(select(Settings))
+        all_settings = result.scalars().all()
+        sensitive_keys = {
+            "access_code",
+            "password",
+            "token",
+            "secret",
+            "api_key",
+            "installation_id",
+            "cloud_token",
+            "mqtt_password",
+            "email",
+            "vapid",
+            "private_key",
+            "public_key",
+            "webhook",
+            "url",
+            "config",  # URLs may contain IPs, configs may have embedded secrets
+        }
+        for s in all_settings:
+            # Skip sensitive settings
+            if any(sensitive in s.key.lower() for sensitive in sensitive_keys):
+                continue
+            info["settings"][s.key] = s.value
+
+    return info
+
+
+def _sanitize_log_content(content: str) -> str:
+    """Remove sensitive data from log content."""
+    import re
+
+    # Replace IP addresses with [IP]
+    content = re.sub(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b", "[IP]", content)
+
+    # Replace email addresses
+    content = re.sub(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", "[EMAIL]", content)
+
+    # Replace paths with usernames
+    content = re.sub(r"/home/[^/\s]+/", "/home/[user]/", content)
+    content = re.sub(r"/Users/[^/\s]+/", "/Users/[user]/", content)
+    content = re.sub(r"/opt/[^/\s]+/", "/opt/[user]/", content)
+
+    return content
+
+
+def _get_log_content(max_bytes: int = 10 * 1024 * 1024) -> bytes:
+    """Get log file content, limited to max_bytes from the end."""
+    log_file = settings.log_dir / "bambuddy.log"
+    if not log_file.exists():
+        return b"Log file not found"
+
+    file_size = log_file.stat().st_size
+    if file_size <= max_bytes:
+        content = log_file.read_text(encoding="utf-8", errors="replace")
+    else:
+        # Read last max_bytes
+        with open(log_file, "rb") as f:
+            f.seek(file_size - max_bytes)
+            # Skip partial line at start
+            f.readline()
+            content = f.read().decode("utf-8", errors="replace")
+
+    # Sanitize sensitive data
+    content = _sanitize_log_content(content)
+    return content.encode("utf-8")
+
+
+@router.get("/bundle")
+async def generate_support_bundle():
+    """Generate a support bundle ZIP file for issue reporting."""
+    global _debug_logging_enabled, _debug_logging_enabled_at
+
+    # Check if debug logging is enabled
+    async with async_session() as db:
+        enabled, enabled_at = await _get_debug_setting(db)
+        _debug_logging_enabled = enabled
+        _debug_logging_enabled_at = enabled_at
+
+    if not enabled:
+        raise HTTPException(
+            status_code=400,
+            detail="Debug logging must be enabled before generating a support bundle. "
+            "Please enable debug logging, reproduce the issue, then generate the bundle.",
+        )
+
+    # Collect support info
+    support_info = await _collect_support_info()
+
+    # Create ZIP in memory
+    zip_buffer = io.BytesIO()
+    timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
+
+    with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+        # Add support info JSON
+        zf.writestr("support-info.json", json.dumps(support_info, indent=2, default=str))
+
+        # Add log file
+        log_content = _get_log_content()
+        zf.writestr("bambuddy.log", log_content)
+
+    zip_buffer.seek(0)
+
+    filename = f"bambuddy-support-{timestamp}.zip"
+    logger.info(f"Generated support bundle: {filename}")
+
+    return StreamingResponse(
+        zip_buffer, media_type="application/zip", headers={"Content-Disposition": f"attachment; filename={filename}"}
+    )
+
+
+async def init_debug_logging():
+    """Initialize debug logging state from database on startup."""
+    global _debug_logging_enabled, _debug_logging_enabled_at
+
+    try:
+        async with async_session() as db:
+            enabled, enabled_at = await _get_debug_setting(db)
+            _debug_logging_enabled = enabled
+            _debug_logging_enabled_at = enabled_at
+
+            if enabled:
+                _apply_log_level(True)
+                logger.info("Debug logging restored from previous session")
+    except Exception as e:
+        logger.warning(f"Could not restore debug logging state: {e}")

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

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

+ 12 - 0
backend/app/core/database.py

@@ -387,6 +387,18 @@ async def run_migrations(conn):
     except Exception:
         pass
 
+    # Migration: Add wiki_url column to maintenance_types for documentation links
+    try:
+        await conn.execute(text("ALTER TABLE maintenance_types ADD COLUMN wiki_url VARCHAR(500)"))
+    except Exception:
+        pass
+
+    # Migration: Add ams_mapping column to print_queue for storing filament slot assignments
+    try:
+        await conn.execute(text("ALTER TABLE print_queue ADD COLUMN ams_mapping TEXT"))
+    except Exception:
+        pass
+
 
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""

+ 102 - 49
backend/app/main.py

@@ -1,7 +1,7 @@
 import asyncio
 import logging
 from contextlib import asynccontextmanager
-from datetime import datetime, timedelta
+from datetime import UTC, datetime, timedelta
 from logging.handlers import RotatingFileHandler
 
 from fastapi import FastAPI
@@ -59,6 +59,7 @@ from backend.app.api.routes import (
     discovery,
     external_links,
     filaments,
+    firmware,
     kprofiles,
     maintenance,
     notification_templates,
@@ -70,17 +71,19 @@ from backend.app.api.routes import (
     settings as settings_routes,
     smart_plugs,
     spoolman,
+    support,
     system,
     updates,
     webhook,
     websocket,
 )
 from backend.app.api.routes.maintenance import _get_printer_maintenance_internal, ensure_default_types
+from backend.app.api.routes.support import init_debug_logging
 from backend.app.core.database import async_session, init_db
 from backend.app.core.websocket import ws_manager
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.services.archive import ArchiveService
-from backend.app.services.bambu_ftp import download_file_async
+from backend.app.services.bambu_ftp import download_file_async, get_ftp_retry_settings, with_ftp_retry
 from backend.app.services.bambu_mqtt import PrinterState
 from backend.app.services.notification_service import notification_service
 from backend.app.services.print_scheduler import scheduler as print_scheduler
@@ -441,8 +444,6 @@ async def on_print_start(printer_id: int, data: dict):
         if expected_archive_id:
             # This is a reprint/scheduled print - use existing archive, don't create new one
             logger.info(f"Using expected archive {expected_archive_id} for print (skipping duplicate)")
-            from datetime import datetime
-
             from backend.app.models.archive import PrintArchive
 
             result = await db.execute(select(PrintArchive).where(PrintArchive.id == expected_archive_id))
@@ -513,30 +514,46 @@ async def on_print_start(printer_id: int, data: dict):
         )
         existing_archive = existing.scalar_one_or_none()
         if existing_archive:
-            logger.info(f"Skipping duplicate - already have printing archive {existing_archive.id} for {check_name}")
-            # Track this as the active print
-            _active_prints[(printer_id, existing_archive.filename)] = existing_archive.id
-            # Also set up energy tracking if not already tracked
-            if existing_archive.id not in _print_energy_start:
-                try:
-                    plug_result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
-                    plug = plug_result.scalar_one_or_none()
-                    if plug:
-                        energy = await tasmota_service.get_energy(plug)
-                        if energy and energy.get("total") is not None:
-                            _print_energy_start[existing_archive.id] = energy["total"]
-                            logger.info(
-                                f"Recorded starting energy for existing archive {existing_archive.id}: {energy['total']} kWh"
-                            )
-                except Exception as e:
-                    logger.warning(f"Failed to record starting energy for existing archive: {e}")
-            # Send notification with archive data (existing archive)
-            if not notification_sent:
-                archive_data = {"print_time_seconds": existing_archive.print_time_seconds}
-                await _send_print_start_notification(printer_id, data, archive_data, logger)
-            # Extract printable objects from the archived 3MF file
-            _load_objects_from_archive(existing_archive, printer_id, logger)
-            return
+            # Check if archive is stale (older than 4 hours) - likely a failed/cancelled print
+            # that didn't get properly updated
+            archive_age = datetime.now(UTC) - existing_archive.created_at.replace(tzinfo=UTC)
+            if archive_age.total_seconds() > 4 * 60 * 60:  # 4 hours
+                logger.warning(
+                    f"Found stale 'printing' archive {existing_archive.id} (age: {archive_age}), "
+                    f"marking as cancelled and creating new archive"
+                )
+                existing_archive.status = "cancelled"
+                existing_archive.failure_reason = "Stale - print likely cancelled or failed without status update"
+                await db.commit()
+                # Fall through to create new archive (don't return)
+                existing_archive = None  # Clear so we don't use stale archive
+            else:
+                logger.info(
+                    f"Skipping duplicate - already have printing archive {existing_archive.id} for {check_name}"
+                )
+                # Track this as the active print
+                _active_prints[(printer_id, existing_archive.filename)] = existing_archive.id
+                # Also set up energy tracking if not already tracked
+                if existing_archive.id not in _print_energy_start:
+                    try:
+                        plug_result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
+                        plug = plug_result.scalar_one_or_none()
+                        if plug:
+                            energy = await tasmota_service.get_energy(plug)
+                            if energy and energy.get("total") is not None:
+                                _print_energy_start[existing_archive.id] = energy["total"]
+                                logger.info(
+                                    f"Recorded starting energy for existing archive {existing_archive.id}: {energy['total']} kWh"
+                                )
+                    except Exception as e:
+                        logger.warning(f"Failed to record starting energy for existing archive: {e}")
+                # Send notification with archive data (existing archive)
+                if not notification_sent:
+                    archive_data = {"print_time_seconds": existing_archive.print_time_seconds}
+                    await _send_print_start_notification(printer_id, data, archive_data, logger)
+                # Extract printable objects from the archived 3MF file
+                _load_objects_from_archive(existing_archive, printer_id, logger)
+                return
 
         # Build list of possible 3MF filenames to try
         possible_names = []
@@ -572,6 +589,9 @@ async def on_print_start(printer_id: int, data: dict):
         temp_path = None
         downloaded_filename = None
 
+        # Get FTP retry settings
+        ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
+
         for try_filename in possible_names:
             if not try_filename.endswith(".3mf"):
                 continue
@@ -588,12 +608,29 @@ async def on_print_start(printer_id: int, data: dict):
             for remote_path in remote_paths:
                 logger.debug(f"Trying FTP download: {remote_path}")
                 try:
-                    if await download_file_async(
-                        printer.ip_address,
-                        printer.access_code,
-                        remote_path,
-                        temp_path,
-                    ):
+                    if ftp_retry_enabled:
+                        downloaded = await with_ftp_retry(
+                            download_file_async,
+                            printer.ip_address,
+                            printer.access_code,
+                            remote_path,
+                            temp_path,
+                            socket_timeout=ftp_timeout,
+                            printer_model=printer.model,
+                            max_retries=ftp_retry_count,
+                            retry_delay=ftp_retry_delay,
+                            operation_name=f"Download 3MF from {remote_path}",
+                        )
+                    else:
+                        downloaded = await download_file_async(
+                            printer.ip_address,
+                            printer.access_code,
+                            remote_path,
+                            temp_path,
+                            socket_timeout=ftp_timeout,
+                            printer_model=printer.model,
+                        )
+                    if downloaded:
                         downloaded_filename = try_filename
                         logger.info(f"Downloaded: {remote_path}")
                         break
@@ -621,12 +658,25 @@ async def on_print_start(printer_id: int, data: dict):
                         logger.info(f"Found matching file: {fname}")
                         temp_path = app_settings.archive_dir / "temp" / fname
                         temp_path.parent.mkdir(parents=True, exist_ok=True)
-                        if await download_file_async(
-                            printer.ip_address,
-                            printer.access_code,
-                            f"/cache/{fname}",
-                            temp_path,
-                        ):
+                        if ftp_retry_enabled:
+                            downloaded = await with_ftp_retry(
+                                download_file_async,
+                                printer.ip_address,
+                                printer.access_code,
+                                f"/cache/{fname}",
+                                temp_path,
+                                max_retries=ftp_retry_count,
+                                retry_delay=ftp_retry_delay,
+                                operation_name=f"Download 3MF from /cache/{fname}",
+                            )
+                        else:
+                            downloaded = await download_file_async(
+                                printer.ip_address,
+                                printer.access_code,
+                                f"/cache/{fname}",
+                                temp_path,
+                            )
+                        if downloaded:
                             downloaded_filename = fname
                             logger.info(f"Found and downloaded from cache: {fname}")
                             break
@@ -1151,7 +1201,7 @@ async def on_print_complete(printer_id: int, data: dict):
         try:
             logger.info(f"[PHOTO-BG] Starting finish photo capture for archive {archive_id}")
 
-            from backend.app.api.routes.camera import _active_streams, get_buffered_frame
+            from backend.app.api.routes.camera import _active_chamber_streams, _active_streams, get_buffered_frame
 
             async with async_session() as db:
                 from backend.app.api.routes.settings import get_setting
@@ -1179,10 +1229,14 @@ async def on_print_complete(printer_id: int, data: dict):
                             photo_filename = None
 
                             # Check if camera stream is active - use buffered frame to avoid freeze
+                            # Check both RTSP streams (_active_streams) and chamber image streams (_active_chamber_streams)
                             active_for_printer = [k for k in _active_streams if k.startswith(f"{printer_id}-")]
+                            active_chamber_for_printer = [
+                                k for k in _active_chamber_streams if k.startswith(f"{printer_id}-")
+                            ]
                             buffered_frame = get_buffered_frame(printer_id)
 
-                            if active_for_printer and buffered_frame:
+                            if (active_for_printer or active_chamber_for_printer) and buffered_frame:
                                 # Use frame from active stream
                                 logger.info("[PHOTO-BG] Using buffered frame from active stream")
                                 photos_dir = archive_dir / "photos"
@@ -1609,8 +1663,7 @@ async def track_printer_runtime():
                         # Calculate time since last update
                         if printer.last_runtime_update:
                             elapsed = (now - printer.last_runtime_update).total_seconds()
-                            # Sanity check: don't add more than 2x the interval (handles server restarts)
-                            if elapsed > 0 and elapsed < RUNTIME_TRACKING_INTERVAL * 2:
+                            if elapsed > 0:
                                 printer.runtime_seconds += int(elapsed)
                                 updated_count += 1
                                 needs_commit = True
@@ -1618,11 +1671,6 @@ async def track_printer_runtime():
                                     f"[{printer.name}] Runtime tracking: added {int(elapsed)}s, "
                                     f"total={printer.runtime_seconds}s ({printer.runtime_seconds / 3600:.2f}h)"
                                 )
-                            else:
-                                logger.warning(
-                                    f"[{printer.name}] Runtime tracking: skipped elapsed={elapsed:.1f}s "
-                                    f"(outside valid range 0-{RUNTIME_TRACKING_INTERVAL * 2}s)"
-                                )
                         else:
                             # First time seeing printer active - need to commit to save timestamp
                             needs_commit = True
@@ -1674,6 +1722,9 @@ async def lifespan(app: FastAPI):
     # Startup
     await init_db()
 
+    # Restore debug logging state from previous session
+    await init_debug_logging()
+
     # Set up printer manager callbacks
     loop = asyncio.get_event_loop()
     printer_manager.set_event_loop(loop)
@@ -1795,9 +1846,11 @@ app.include_router(api_keys.router, prefix=app_settings.api_prefix)
 app.include_router(webhook.router, prefix=app_settings.api_prefix)
 app.include_router(ams_history.router, prefix=app_settings.api_prefix)
 app.include_router(system.router, prefix=app_settings.api_prefix)
+app.include_router(support.router, prefix=app_settings.api_prefix)
 app.include_router(websocket.router, prefix=app_settings.api_prefix)
 app.include_router(discovery.router, prefix=app_settings.api_prefix)
 app.include_router(pending_uploads.router, prefix=app_settings.api_prefix)
+app.include_router(firmware.router, prefix=app_settings.api_prefix)
 
 
 # Serve static files (React build)

+ 1 - 0
backend/app/models/maintenance.py

@@ -20,6 +20,7 @@ class MaintenanceType(Base):
     # Interval type: "hours" (print hours) or "days" (calendar days)
     interval_type: Mapped[str] = mapped_column(String(20), default="hours")
     icon: Mapped[str | None] = mapped_column(String(50))  # Icon name for UI
+    wiki_url: Mapped[str | None] = mapped_column(String(500))  # Documentation link
     is_system: Mapped[bool] = mapped_column(Boolean, default=False)  # Pre-defined vs custom
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
 

+ 4 - 0
backend/app/models/print_queue.py

@@ -29,6 +29,10 @@ class PrintQueueItem(Base):
     # Power management
     auto_off_after: Mapped[bool] = mapped_column(Boolean, default=False)  # Power off printer after print
 
+    # AMS mapping: JSON array of global tray IDs for each filament slot
+    # Format: "[5, -1, 2, -1]" where position = slot_id-1, value = global tray ID (-1 = unused)
+    ams_mapping: Mapped[str | None] = mapped_column(Text, nullable=True)
+
     # Status: pending, printing, completed, failed, skipped, cancelled
     status: Mapped[str] = mapped_column(String(20), default="pending")
 

+ 29 - 1
backend/app/schemas/archive.py

@@ -1,6 +1,6 @@
 from datetime import datetime
 
-from pydantic import BaseModel
+from pydantic import BaseModel, model_validator
 
 
 class ArchiveBase(BaseModel):
@@ -45,6 +45,9 @@ class ArchiveResponse(BaseModel):
     duplicates: list[ArchiveDuplicate] | None = None
     duplicate_count: int = 0  # Quick count for list views
 
+    # Object count (computed from extra_data.printable_objects)
+    object_count: int | None = None
+
     print_name: str | None
     print_time_seconds: int | None  # Estimated time from slicer
     actual_time_seconds: int | None = None  # Computed from started_at/completed_at
@@ -81,6 +84,15 @@ class ArchiveResponse(BaseModel):
 
     created_at: datetime
 
+    @model_validator(mode="after")
+    def compute_object_count(self) -> "ArchiveResponse":
+        """Compute object_count from extra_data.printable_objects if not set."""
+        if self.object_count is None and self.extra_data:
+            printable_objects = self.extra_data.get("printable_objects")
+            if printable_objects and isinstance(printable_objects, dict):
+                self.object_count = len(printable_objects)
+        return self
+
     class Config:
         from_attributes = True
 
@@ -152,3 +164,19 @@ class ProjectPageUpdate(BaseModel):
     copyright: str | None = None
     profile_title: str | None = None
     profile_description: str | None = None
+
+
+class ReprintRequest(BaseModel):
+    """Request body for reprinting an archive."""
+
+    # AMS slot mapping: list of tray IDs for each filament slot in the 3MF
+    # Global tray ID = (ams_id * 4) + slot_id, external = 254
+    ams_mapping: list[int] | None = None
+
+    # Print options
+    bed_levelling: bool = True
+    flow_cali: bool = False
+    vibration_cali: bool = True
+    layer_inspect: bool = False
+    timelapse: bool = False
+    use_ams: bool = True  # Not exposed in UI, but needed for API

+ 18 - 0
backend/app/schemas/cloud.py

@@ -106,3 +106,21 @@ class SlicerSettingDeleteResponse(BaseModel):
 
     success: bool
     message: str
+
+
+class FirmwareUpdateInfo(BaseModel):
+    """Firmware update information for a device."""
+
+    device_id: str = Field(..., description="Device ID")
+    device_name: str = Field(..., description="Device name")
+    current_version: str | None = Field(None, description="Currently installed firmware version")
+    latest_version: str | None = Field(None, description="Latest available firmware version")
+    update_available: bool = Field(False, description="Whether an update is available")
+    release_notes: str | None = Field(None, description="Release notes for the latest version")
+
+
+class FirmwareUpdatesResponse(BaseModel):
+    """Response containing firmware updates for all devices."""
+
+    updates: list[FirmwareUpdateInfo] = Field(default_factory=list)
+    updates_available: int = Field(0, description="Total number of devices with updates available")

+ 5 - 0
backend/app/schemas/maintenance.py

@@ -13,6 +13,7 @@ class MaintenanceTypeBase(BaseModel):
     # "hours" = print hours, "days" = calendar days
     interval_type: str = Field(default="hours", pattern="^(hours|days)$")
     icon: str | None = None
+    wiki_url: str | None = None  # Documentation link for custom types
 
 
 class MaintenanceTypeCreate(MaintenanceTypeBase):
@@ -25,6 +26,7 @@ class MaintenanceTypeUpdate(BaseModel):
     default_interval_hours: float | None = Field(default=None, ge=1.0)
     interval_type: str | None = Field(default=None, pattern="^(hours|days)$")
     icon: str | None = None
+    wiki_url: str | None = None
 
 
 class MaintenanceTypeResponse(MaintenanceTypeBase):
@@ -96,9 +98,11 @@ class MaintenanceStatus(BaseModel):
     id: int
     printer_id: int
     printer_name: str
+    printer_model: str | None  # For model-specific documentation links
     maintenance_type_id: int
     maintenance_type_name: str
     maintenance_type_icon: str | None
+    maintenance_type_wiki_url: str | None  # Custom wiki URL for the type
     enabled: bool
     # Interval configuration
     interval_hours: float  # custom or default (hours for print-based, days for time-based)
@@ -121,6 +125,7 @@ class PrinterMaintenanceOverview(BaseModel):
 
     printer_id: int
     printer_name: str
+    printer_model: str | None  # For model-specific documentation links
     total_print_hours: float
     maintenance_items: list[MaintenanceStatus]
     due_count: int

+ 5 - 0
backend/app/schemas/print_queue.py

@@ -22,6 +22,9 @@ class PrintQueueItemCreate(BaseModel):
     require_previous_success: bool = False
     auto_off_after: bool = False  # Power off printer after print completes
     manual_start: bool = False  # Requires manual trigger to start (staged)
+    # AMS mapping: list of global tray IDs for each filament slot
+    # Format: [5, -1, 2, -1] where position = slot_id-1, value = global tray ID (-1 = unused)
+    ams_mapping: list[int] | None = None
 
 
 class PrintQueueItemUpdate(BaseModel):
@@ -31,6 +34,7 @@ class PrintQueueItemUpdate(BaseModel):
     require_previous_success: bool | None = None
     auto_off_after: bool | None = None
     manual_start: bool | None = None
+    ams_mapping: list[int] | None = None
 
 
 class PrintQueueItemResponse(BaseModel):
@@ -42,6 +46,7 @@ class PrintQueueItemResponse(BaseModel):
     require_previous_success: bool
     auto_off_after: bool
     manual_start: bool
+    ams_mapping: list[int] | None = None
     status: Literal["pending", "printing", "completed", "failed", "skipped", "cancelled"]
     started_at: UTCDatetime
     completed_at: UTCDatetime

+ 2 - 0
backend/app/schemas/printer.py

@@ -158,3 +158,5 @@ class PrinterStatus(BaseModel):
     big_fan1_speed: int | None = None  # Auxiliary fan
     big_fan2_speed: int | None = None  # Chamber/exhaust fan
     heatbreak_fan_speed: int | None = None  # Hotend heatbreak fan
+    # Firmware version (from info.module[name="ota"].sw_ver)
+    firmware_version: str | None = None

+ 8 - 0
backend/app/schemas/settings.py

@@ -68,6 +68,11 @@ class AppSettings(BaseModel):
     light_background: str = Field(default="neutral", description="Light mode background: neutral, warm, cool")
     light_accent: str = Field(default="green", description="Light mode accent: green, teal, blue, orange, purple, red")
 
+    # FTP retry settings for unreliable WiFi connections
+    ftp_retry_enabled: bool = Field(default=True, description="Enable automatic retry for FTP operations")
+    ftp_retry_count: int = Field(default=3, description="Number of retry attempts for FTP operations (1-10)")
+    ftp_retry_delay: int = Field(default=2, description="Seconds to wait between FTP retry attempts (1-30)")
+
 
 class AppSettingsUpdate(BaseModel):
     """Schema for updating settings (all fields optional)."""
@@ -102,3 +107,6 @@ class AppSettingsUpdate(BaseModel):
     light_style: str | None = None
     light_background: str | None = None
     light_accent: str | None = None
+    ftp_retry_enabled: bool | None = None
+    ftp_retry_count: int | None = None
+    ftp_retry_delay: int | None = None

+ 53 - 11
backend/app/services/archive.py

@@ -1,5 +1,6 @@
 import hashlib
 import json
+import logging
 import re
 import shutil
 import zipfile
@@ -15,6 +16,8 @@ from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
 from backend.app.models.printer import Printer
 
+logger = logging.getLogger(__name__)
+
 
 class ThreeMFParser:
     """Parser for Bambu Lab 3MF files."""
@@ -393,8 +396,8 @@ def extract_printable_objects_from_3mf(
                     break
 
             # Load position data from plate_N.json if we need positions
-            # Build a lookup by name since bbox_objects.id != slice_info identify_id
-            bbox_by_name: dict[str, list] = {}
+            # Build a lookup by name - use list to handle duplicate names
+            bbox_by_name: dict[str, list[list]] = {}
             if include_positions:
                 plate_json_path = f"Metadata/plate_{plate_idx}.json"
                 if plate_json_path in zf.namelist():
@@ -406,7 +409,9 @@ def extract_printable_objects_from_3mf(
                             obj_name = bbox_obj.get("name")
                             bbox = bbox_obj.get("bbox", [])
                             if obj_name and len(bbox) >= 4:
-                                bbox_by_name[obj_name] = bbox
+                                if obj_name not in bbox_by_name:
+                                    bbox_by_name[obj_name] = []
+                                bbox_by_name[obj_name].append(bbox)
                     except (json.JSONDecodeError, KeyError):
                         pass
 
@@ -421,9 +426,10 @@ def extract_printable_objects_from_3mf(
                         obj_id = int(identify_id)
                         if include_positions:
                             x, y = None, None
-                            # Match by name to get bbox coordinates
-                            bbox = bbox_by_name.get(name)
-                            if bbox:
+                            # Match by name - pop first bbox to handle duplicates
+                            bboxes = bbox_by_name.get(name)
+                            if bboxes:
+                                bbox = bboxes.pop(0)
                                 # Calculate center from bbox [x_min, y_min, x_max, y_max]
                                 x = (bbox[0] + bbox[2]) / 2
                                 y = (bbox[1] + bbox[3]) / 2
@@ -917,11 +923,47 @@ class ArchiveService:
         if not archive:
             return False
 
-        # Delete files
-        file_path = settings.base_dir / archive.file_path
-        if file_path.exists():
-            archive_dir = file_path.parent
-            shutil.rmtree(archive_dir, ignore_errors=True)
+        # Delete files - with CRITICAL safety checks to prevent accidental deletion
+        # of parent directories (e.g., /opt) if file_path is empty/malformed
+        if archive.file_path and archive.file_path.strip():
+            file_path = settings.base_dir / archive.file_path
+            if file_path.exists():
+                archive_dir = file_path.parent
+
+                # Safety check 1: archive_dir must be inside archive_dir
+                try:
+                    archive_dir.resolve().relative_to(settings.archive_dir.resolve())
+                except ValueError:
+                    logger.error(
+                        f"SECURITY: Refusing to delete archive {archive_id} - "
+                        f"path {archive_dir} is outside archive directory {settings.archive_dir}"
+                    )
+                    # Still delete the database record, just not the files
+                    await self.db.delete(archive)
+                    await self.db.commit()
+                    return True
+
+                # Safety check 2: archive_dir must be at least 1 level deep inside archive_dir
+                # (should be archive_dir/uuid/file.3mf, so parent should be archive_dir/uuid)
+                try:
+                    relative_path = archive_dir.resolve().relative_to(settings.archive_dir.resolve())
+                    if len(relative_path.parts) < 1:
+                        logger.error(
+                            f"SECURITY: Refusing to delete archive {archive_id} - "
+                            f"path {archive_dir} is not deep enough inside archive directory"
+                        )
+                        await self.db.delete(archive)
+                        await self.db.commit()
+                        return True
+                except ValueError:
+                    pass  # Already handled above
+
+                shutil.rmtree(archive_dir, ignore_errors=True)
+        else:
+            logger.error(
+                f"SECURITY: Refusing to delete files for archive {archive_id} - "
+                f"file_path is empty or invalid: '{archive.file_path}'"
+            )
 
         # Delete database record
         await self.db.delete(archive)

+ 30 - 0
backend/app/services/bambu_cloud.py

@@ -378,6 +378,36 @@ class BambuCloudService:
         except httpx.RequestError as e:
             raise BambuCloudError(f"Request failed: {e}")
 
+    async def get_firmware_version(self, device_id: str) -> dict:
+        """
+        Get firmware version info for a device.
+
+        Returns dict with:
+        - current_version: Installed firmware version
+        - latest_version: Latest available firmware version
+        - update_available: Boolean indicating if update is available
+        - release_notes: Release notes for latest version
+        """
+        if not self.is_authenticated:
+            raise BambuCloudAuthError("Not authenticated")
+
+        try:
+            response = await self._client.get(
+                f"{self.base_url}/v1/iot-service/api/user/device/version",
+                headers=self._get_headers(),
+                params={"device_id": device_id},
+            )
+
+            if response.status_code == 200:
+                data = response.json()
+                # API wraps response in 'data' field
+                return data.get("data", data)
+
+            raise BambuCloudError(f"Failed to get firmware version: {response.status_code}")
+
+        except httpx.RequestError as e:
+            raise BambuCloudError(f"Request failed: {e}")
+
     async def close(self):
         """Close the HTTP client."""
         await self._client.aclose()

+ 225 - 30
backend/app/services/bambu_ftp.py

@@ -1,20 +1,33 @@
 import asyncio
+import ftplib
 import logging
+import os
 import socket
 import ssl
+from collections.abc import Awaitable, Callable
 from ftplib import FTP, FTP_TLS
 from io import BytesIO
 from pathlib import Path
+from typing import TypeVar
 
 logger = logging.getLogger(__name__)
 
+T = TypeVar("T")
+
 
 class ImplicitFTP_TLS(FTP_TLS):
-    """FTP_TLS subclass for implicit FTPS (port 990) with session reuse."""
+    """FTP_TLS subclass for implicit FTPS (port 990) with model-specific SSL handling.
+
+    X1C/P1S printers (vsFTPd) require SSL with session reuse on the data channel.
+    A1/A1 Mini printers have issues with SSL on the data channel entirely and
+    timeout waiting for transfer completion. Set skip_session_reuse=True for A1
+    printers to skip SSL on the data channel (control channel remains encrypted).
+    """
 
-    def __init__(self, *args, **kwargs):
+    def __init__(self, *args, skip_session_reuse: bool = False, **kwargs):
         super().__init__(*args, **kwargs)
         self._sock = None
+        self.skip_session_reuse = skip_session_reuse
         self.ssl_context = ssl.create_default_context()
         self.ssl_context.check_hostname = False
         self.ssl_context.verify_mode = ssl.CERT_NONE
@@ -39,15 +52,23 @@ class ImplicitFTP_TLS(FTP_TLS):
         return self.welcome
 
     def ntransfercmd(self, cmd, rest=None):
-        """Override to reuse SSL session for data connection (required by vsFTPd)."""
+        """Override to wrap data connection in SSL for X1C/P1S only.
+
+        X1C/P1S printers (vsFTPd) require SSL session reuse on the data channel.
+        A1/A1 Mini printers have issues with SSL on the data channel entirely -
+        they timeout waiting for the transfer completion response. For A1, we
+        skip SSL wrapping on the data channel (control channel remains encrypted).
+        """
         conn, size = FTP.ntransfercmd(self, cmd, rest)
-        if self._prot_p:
-            # Reuse the SSL session from the control connection
+        if self._prot_p and not self.skip_session_reuse:
+            # X1C/P1S: Wrap data channel with SSL session reuse (required by vsFTPd)
             conn = self.ssl_context.wrap_socket(
                 conn,
                 server_hostname=self.host,
-                session=self.sock.session,  # Reuse session!
+                session=self.sock.session,
             )
+        # A1/A1 Mini (skip_session_reuse=True): Don't wrap data channel in SSL
+        # The control channel remains encrypted via implicit FTPS
         return conn, size
 
 
@@ -55,18 +76,39 @@ class BambuFTPClient:
     """FTP client for retrieving files from Bambu Lab printers."""
 
     FTP_PORT = 990
-
-    def __init__(self, ip_address: str, access_code: str):
+    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)
+    SKIP_SESSION_REUSE_MODELS = ("A1", "A1 Mini", "P1S", "P1P")
+
+    def __init__(
+        self,
+        ip_address: str,
+        access_code: str,
+        timeout: float | None = None,
+        printer_model: str | None = None,
+    ):
         self.ip_address = ip_address
         self.access_code = access_code
+        self.timeout = timeout if timeout is not None else self.DEFAULT_TIMEOUT
+        self.printer_model = printer_model
         self._ftp: ImplicitFTP_TLS | None = None
 
+    def _should_skip_session_reuse(self) -> bool:
+        """Check if this printer model needs SSL session reuse disabled."""
+        if not self.printer_model:
+            return False
+        return self.printer_model in self.SKIP_SESSION_REUSE_MODELS
+
     def connect(self) -> bool:
         """Connect to the printer FTP server (implicit FTPS on port 990)."""
         try:
-            logger.debug(f"FTP connecting to {self.ip_address}:{self.FTP_PORT}")
+            skip_reuse = self._should_skip_session_reuse()
+            logger.debug(
+                f"FTP connecting to {self.ip_address}:{self.FTP_PORT} "
+                f"(timeout={self.timeout}s, model={self.printer_model}, skip_session_reuse={skip_reuse})"
+            )
             self._ftp = ImplicitFTP_TLS()
-            self._ftp.connect(self.ip_address, self.FTP_PORT, timeout=10)
+            self._ftp.connect(self.ip_address, self.FTP_PORT, timeout=self.timeout)
             logger.debug("FTP connected, logging in as bblp")
             self._ftp.login("bblp", self.access_code)
             logger.debug("FTP logged in, setting prot_p and passive mode")
@@ -169,6 +211,8 @@ class BambuFTPClient:
             local_path.parent.mkdir(parents=True, exist_ok=True)
             with open(local_path, "wb") as f:
                 self._ftp.retrbinary(f"RETR {remote_path}", f.write)
+                f.flush()
+                os.fsync(f.fileno())
             file_size = local_path.stat().st_size if local_path.exists() else 0
             logger.info(f"Successfully downloaded {remote_path} to {local_path} ({file_size} bytes)")
             return True
@@ -183,8 +227,13 @@ class BambuFTPClient:
                     pass
             return False
 
-    def upload_file(self, local_path: Path, remote_path: str) -> bool:
-        """Upload a file to the printer."""
+    def upload_file(
+        self,
+        local_path: Path,
+        remote_path: str,
+        progress_callback: Callable[[int, int], None] | None = None,
+    ) -> bool:
+        """Upload a file to the printer with optional progress callback."""
         if not self._ftp:
             logger.warning("upload_file: FTP not connected")
             return False
@@ -192,8 +241,20 @@ class BambuFTPClient:
         try:
             file_size = local_path.stat().st_size if local_path.exists() else 0
             logger.info(f"FTP uploading {local_path} ({file_size} bytes) to {remote_path}")
+
+            uploaded = 0
+
+            def on_block(block: bytes):
+                nonlocal uploaded
+                uploaded += len(block)
+                if progress_callback:
+                    progress_callback(uploaded, file_size)
+
             with open(local_path, "rb") as f:
-                self._ftp.storbinary(f"STOR {remote_path}", f)
+                if self._should_skip_session_reuse():
+                    ftplib._SSLSocket = None
+
+                self._ftp.storbinary(f"STOR {remote_path}", f, callback=on_block)
             logger.info(f"FTP upload complete: {remote_path}")
             return True
         except Exception as e:
@@ -293,12 +354,24 @@ async def download_file_async(
     remote_path: str,
     local_path: Path,
     timeout: float = 60.0,
+    socket_timeout: float | None = None,
+    printer_model: str | None = None,
 ) -> bool:
-    """Async wrapper for downloading a file with timeout."""
+    """Async wrapper for downloading a file with timeout.
+
+    Args:
+        ip_address: Printer IP address
+        access_code: Printer access code
+        remote_path: Remote file path on printer
+        local_path: Local path to save file
+        timeout: Overall operation timeout (asyncio)
+        socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
+        printer_model: Printer model for A1-specific workarounds
+    """
     loop = asyncio.get_event_loop()
 
     def _download():
-        client = BambuFTPClient(ip_address, access_code)
+        client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
         if client.connect():
             try:
                 return client.download_to_file(remote_path, local_path)
@@ -318,12 +391,19 @@ async def download_file_try_paths_async(
     access_code: str,
     remote_paths: list[str],
     local_path: Path,
+    socket_timeout: float | None = None,
+    printer_model: str | None = None,
 ) -> bool:
-    """Try downloading a file from multiple paths using a single connection."""
+    """Try downloading a file from multiple paths using a single connection.
+
+    Args:
+        socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
+        printer_model: Printer model for A1-specific workarounds
+    """
     loop = asyncio.get_event_loop()
 
     def _download():
-        client = BambuFTPClient(ip_address, access_code)
+        client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
         if not client.connect():
             return False
 
@@ -340,23 +420,44 @@ async def upload_file_async(
     access_code: str,
     local_path: Path,
     remote_path: str,
+    timeout: float = 600.0,
+    progress_callback: Callable[[int, int], None] | None = None,
+    socket_timeout: float | None = None,
+    printer_model: str | None = None,
 ) -> bool:
-    """Async wrapper for uploading a file."""
+    """Async wrapper for uploading a file with timeout and progress callback.
+
+    Args:
+        ip_address: Printer IP address
+        access_code: Printer access code
+        local_path: Local file path to upload
+        remote_path: Remote path on printer
+        timeout: Overall operation timeout (asyncio)
+        progress_callback: Optional callback for progress updates
+        socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
+        printer_model: Printer model for A1-specific workarounds
+    """
     loop = asyncio.get_event_loop()
 
     def _upload():
-        logger.info(f"FTP connecting to {ip_address} for upload...")
-        client = BambuFTPClient(ip_address, access_code)
+        logger.info(
+            f"FTP connecting to {ip_address} for upload (model={printer_model}, socket_timeout={socket_timeout}s)..."
+        )
+        client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
         if client.connect():
             logger.info(f"FTP connected to {ip_address}")
             try:
-                return client.upload_file(local_path, remote_path)
+                return client.upload_file(local_path, remote_path, progress_callback)
             finally:
                 client.disconnect()
         logger.warning(f"FTP connection failed to {ip_address}")
         return False
 
-    return await loop.run_in_executor(None, _upload)
+    try:
+        return await asyncio.wait_for(loop.run_in_executor(None, _upload), timeout=timeout)
+    except TimeoutError:
+        logger.warning(f"FTP upload timed out after {timeout}s for {remote_path}")
+        return False
 
 
 async def list_files_async(
@@ -364,12 +465,19 @@ async def list_files_async(
     access_code: str,
     path: str = "/",
     timeout: float = 30.0,
+    socket_timeout: float | None = None,
+    printer_model: str | None = None,
 ) -> list[dict]:
-    """Async wrapper for listing files with timeout."""
+    """Async wrapper for listing files with timeout.
+
+    Args:
+        socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
+        printer_model: Printer model for A1-specific workarounds
+    """
     loop = asyncio.get_event_loop()
 
     def _list():
-        client = BambuFTPClient(ip_address, access_code)
+        client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
         if client.connect():
             try:
                 return client.list_files(path)
@@ -388,12 +496,19 @@ async def delete_file_async(
     ip_address: str,
     access_code: str,
     remote_path: str,
+    socket_timeout: float | None = None,
+    printer_model: str | None = None,
 ) -> bool:
-    """Async wrapper for deleting a file."""
+    """Async wrapper for deleting a file.
+
+    Args:
+        socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
+        printer_model: Printer model for A1-specific workarounds
+    """
     loop = asyncio.get_event_loop()
 
     def _delete():
-        client = BambuFTPClient(ip_address, access_code)
+        client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
         if client.connect():
             try:
                 return client.delete_file(remote_path)
@@ -408,12 +523,19 @@ async def download_file_bytes_async(
     ip_address: str,
     access_code: str,
     remote_path: str,
+    socket_timeout: float | None = None,
+    printer_model: str | None = None,
 ) -> bytes | None:
-    """Async wrapper for downloading file as bytes."""
+    """Async wrapper for downloading file as bytes.
+
+    Args:
+        socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
+        printer_model: Printer model for A1-specific workarounds
+    """
     loop = asyncio.get_event_loop()
 
     def _download():
-        client = BambuFTPClient(ip_address, access_code)
+        client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
         if client.connect():
             try:
                 return client.download_file(remote_path)
@@ -427,12 +549,19 @@ async def download_file_bytes_async(
 async def get_storage_info_async(
     ip_address: str,
     access_code: str,
+    socket_timeout: float | None = None,
+    printer_model: str | None = None,
 ) -> dict | None:
-    """Async wrapper for getting storage info."""
+    """Async wrapper for getting storage info.
+
+    Args:
+        socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
+        printer_model: Printer model for A1-specific workarounds
+    """
     loop = asyncio.get_event_loop()
 
     def _get_storage():
-        client = BambuFTPClient(ip_address, access_code)
+        client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
         if client.connect():
             try:
                 return client.get_storage_info()
@@ -441,3 +570,69 @@ async def get_storage_info_async(
         return None
 
     return await loop.run_in_executor(None, _get_storage)
+
+
+async def get_ftp_retry_settings() -> tuple[bool, int, float, float]:
+    """Get FTP retry settings from database.
+
+    Returns:
+        Tuple of (retry_enabled, retry_count, retry_delay, timeout)
+    """
+    from backend.app.api.routes.settings import get_setting
+    from backend.app.core.database import async_session
+
+    async with async_session() as db:
+        enabled = (await get_setting(db, "ftp_retry_enabled") or "true") == "true"
+        count = int(await get_setting(db, "ftp_retry_count") or "3")
+        delay = float(await get_setting(db, "ftp_retry_delay") or "2")
+        timeout = float(await get_setting(db, "ftp_timeout") or "30")
+    return enabled, count, delay, timeout
+
+
+async def with_ftp_retry(
+    operation: Callable[..., Awaitable[T]],
+    *args,
+    max_retries: int = 3,
+    retry_delay: float = 2.0,
+    operation_name: str = "FTP operation",
+    **kwargs,
+) -> T | None:
+    """Execute FTP operation with retry logic.
+
+    Args:
+        operation: Async function to execute
+        *args: Positional arguments for the operation
+        max_retries: Number of retry attempts (default: 3)
+        retry_delay: Seconds to wait between retries (default: 2.0)
+        operation_name: Name for logging purposes
+        **kwargs: Keyword arguments for the operation
+
+    Returns:
+        Result of the operation, or None if all attempts fail
+    """
+    last_error = None
+
+    for attempt in range(max_retries + 1):
+        try:
+            result = await operation(*args, **kwargs)
+            # Check for "falsy" success indicators
+            if result not in (False, None, []):
+                if attempt > 0:
+                    logger.info(f"{operation_name} succeeded on attempt {attempt + 1}/{max_retries + 1}")
+                return result
+            # Operation returned failure indicator
+            if attempt > 0:
+                logger.info(f"{operation_name} attempt {attempt + 1}/{max_retries + 1} returned failure")
+        except Exception as e:
+            last_error = e
+            logger.warning(f"{operation_name} attempt {attempt + 1}/{max_retries + 1} failed: {e}")
+
+        # Don't wait after the last attempt
+        if attempt < max_retries:
+            logger.info(f"{operation_name} will retry in {retry_delay}s...")
+            await asyncio.sleep(retry_delay)
+
+    logger.error(f"{operation_name} failed after {max_retries + 1} attempts")
+    if last_error:
+        logger.debug(f"Last error: {last_error}")
+    return None

+ 119 - 11
backend/app/services/bambu_mqtt.py

@@ -158,6 +158,8 @@ class PrinterState:
     big_fan1_speed: int | None = None  # Auxiliary fan
     big_fan2_speed: int | None = None  # Chamber/exhaust fan
     heatbreak_fan_speed: int | None = None  # Hotend heatbreak fan
+    # Firmware version info (from info.module[name="ota"].sw_ver)
+    firmware_version: str | None = None
 
 
 # Stage name mapping from BambuStudio DeviceManager.cpp
@@ -323,6 +325,8 @@ class BambuMQTTClient:
             client.subscribe(self.topic_subscribe)
             # Request full status update (includes nozzle info in push_status response)
             self._request_push_all()
+            # Request firmware version info
+            self._request_version()
             # Note: get_accessories returns stale nozzle data on H2D, so we don't use it.
             # The correct nozzle data comes from push_status.
             # Prime K-profile request (Bambu printers often ignore first request)
@@ -397,6 +401,12 @@ class BambuMQTTClient:
             logger.info(f"[{self.serial_number}] Received system data: {system_data}")
             self._handle_system_response(system_data)
 
+        # Handle info responses (firmware version info from get_version command)
+        if "info" in payload:
+            info_data = payload["info"]
+            if isinstance(info_data, dict) and info_data.get("command") == "get_version":
+                self._handle_version_info(info_data)
+
         # Parse WiFi signal at top level (some printers send it here)
         if "wifi_signal" in payload:
             wifi_signal = payload["wifi_signal"]
@@ -487,6 +497,39 @@ class BambuMQTTClient:
             # actual nozzle is 'HH01' hardened steel high-flow)
             logger.info(f"[{self.serial_number}] Accessories response (not used for nozzle data): {data}")
 
+    def _handle_version_info(self, data: dict):
+        """Handle version info response from get_version command.
+
+        Parses firmware version from the 'ota' module in the module list.
+        Message format:
+        {
+            "command": "get_version",
+            "module": [
+                {"name": "ota", "sw_ver": "01.08.05.00"},
+                {"name": "rv1126", "sw_ver": "00.00.14.74"},
+                ...
+            ]
+        }
+        """
+        modules = data.get("module", [])
+        if not isinstance(modules, list):
+            return
+
+        for module in modules:
+            if not isinstance(module, dict):
+                continue
+            if module.get("name") == "ota":
+                version = module.get("sw_ver")
+                if version:
+                    old_version = self.state.firmware_version
+                    self.state.firmware_version = version
+                    if old_version != version:
+                        logger.info(f"[{self.serial_number}] Firmware version: {version}")
+                    # Trigger state change callback
+                    if self.on_state_change:
+                        self.on_state_change(self.state)
+                break
+
     def _parse_xcam_data(self, xcam_data):
         """Parse xcam data for camera settings and AI detection options."""
         if not isinstance(xcam_data, dict):
@@ -1766,6 +1809,19 @@ class BambuMQTTClient:
             message = {"pushing": {"command": "pushall"}}
             self._client.publish(self.topic_publish, json.dumps(message), qos=1)
 
+    def _request_version(self):
+        """Request firmware version info from printer."""
+        if self._client:
+            self._sequence_id += 1
+            message = {
+                "info": {
+                    "sequence_id": str(self._sequence_id),
+                    "command": "get_version",
+                }
+            }
+            logger.debug(f"[{self.serial_number}] Requesting firmware version info")
+            self._client.publish(self.topic_publish, json.dumps(message), qos=1)
+
     def request_status_update(self) -> bool:
         """Request a full status update from the printer (public API).
 
@@ -1845,29 +1901,81 @@ class BambuMQTTClient:
         self._client.connect_async(self.ip_address, self.MQTT_PORT, keepalive=15)
         self._client.loop_start()
 
-    def start_print(self, filename: str, plate_id: int = 1):
+    def start_print(
+        self,
+        filename: str,
+        plate_id: int = 1,
+        ams_mapping: list[int] | None = None,
+        bed_levelling: bool = True,
+        flow_cali: bool = False,
+        vibration_cali: bool = True,
+        layer_inspect: bool = False,
+        timelapse: bool = False,
+        use_ams: bool = True,
+    ):
         """Start a print job on the printer.
 
         The file should already be uploaded to /cache/ on the printer via FTP.
+
+        Args:
+            filename: Name of the uploaded file
+            plate_id: Plate number to print (default 1)
+            ams_mapping: List of tray IDs for each filament slot in the 3MF.
+                         Global tray ID = (ams_id * 4) + slot_id, external = 254
+            timelapse: Record timelapse video
+            bed_levelling: Auto bed levelling before print
+            flow_cali: Flow/pressure advance calibration
+            vibration_cali: Vibration compensation calibration
+            layer_inspect: First layer AI inspection
+            use_ams: Use AMS for automatic filament changes
         """
         if self._client and self.state.connected:
-            # Bambu print command format
-            # Based on: https://github.com/darkorb/bambu-ftp-and-print
+            # Bambu print command format - matches Bambu Studio's format
+            # Build ams_mapping2 from ams_mapping (detailed format with ams_id/slot_id)
+            ams_mapping2 = []
+            if ams_mapping is not None:
+                for tray_id in ams_mapping:
+                    if tray_id == -1 or tray_id == 255:
+                        ams_mapping2.append({"ams_id": 255, "slot_id": 255})
+                    else:
+                        # Global tray ID = (ams_id * 4) + slot_id
+                        ams_id = tray_id // 4
+                        slot_id = tray_id % 4
+                        ams_mapping2.append({"ams_id": ams_id, "slot_id": slot_id})
+
             command = {
                 "print": {
-                    "sequence_id": 0,
+                    "sequence_id": "20000",
                     "command": "project_file",
                     "param": f"Metadata/plate_{plate_id}.gcode",
-                    "subtask_name": filename,
                     "url": f"ftp://{filename}",
-                    "timelapse": False,
-                    "bed_leveling": True,
-                    "flow_cali": True,
-                    "vibration_cali": True,
-                    "layer_inspect": False,
-                    "use_ams": True,
+                    "file": filename,
+                    "md5": "",
+                    "bed_type": "auto",
+                    "timelapse": timelapse,
+                    "bed_leveling": bed_levelling,
+                    "auto_bed_leveling": 1 if bed_levelling else 0,
+                    "flow_cali": flow_cali,
+                    "vibration_cali": vibration_cali,
+                    "layer_inspect": layer_inspect,
+                    "use_ams": use_ams,
+                    "cfg": "0",
+                    "extrude_cali_flag": 0,
+                    "extrude_cali_manual_mode": 0,
+                    "nozzle_offset_cali": 2,
+                    "subtask_name": filename.replace(".3mf", "").replace(".gcode", ""),
+                    "profile_id": "0",
+                    "project_id": "0",
+                    "subtask_id": "0",
+                    "task_id": "0",
                 }
             }
+
+            # Add AMS mapping if provided
+            if ams_mapping is not None:
+                command["print"]["ams_mapping"] = ams_mapping
+                command["print"]["ams_mapping2"] = ams_mapping2
+
             logger.info(f"[{self.serial_number}] Sending print command: {json.dumps(command)}")
             self._client.publish(self.topic_publish, json.dumps(command), qos=1)
             return True

+ 7 - 6
backend/app/services/discovery.py

@@ -49,8 +49,9 @@ SSDP_PORT = 2021  # Bambu Lab uses non-standard port
 # Bambu Lab SSDP search target
 BAMBU_SEARCH_TARGET = "urn:bambulab-com:device:3dprinter:1"
 
-# Virtual printer serial to exclude from discovery (Bambuddy's own virtual printer)
-VIRTUAL_PRINTER_SERIAL = "00M09A391800001"
+# Virtual printer serial suffix to exclude from discovery (Bambuddy's own virtual printer)
+# All virtual printer serials end with this suffix, regardless of model
+VIRTUAL_PRINTER_SERIAL_SUFFIX = "391800001"
 
 # SSDP M-SEARCH message
 SSDP_MSEARCH = (
@@ -271,8 +272,8 @@ class PrinterDiscoveryService:
 
         serial = usn_match.group(1).strip()
 
-        # Skip Bambuddy's own virtual printer
-        if serial == VIRTUAL_PRINTER_SERIAL:
+        # Skip Bambuddy's own virtual printer (any model variant)
+        if serial.endswith(VIRTUAL_PRINTER_SERIAL_SUFFIX):
             logger.debug(f"Ignoring Bambuddy virtual printer at {ip_address}")
             return
 
@@ -403,8 +404,8 @@ class SubnetScanner:
         # Try to get printer info via SSDP unicast
         serial, name, model = await self._get_printer_info_ssdp(ip, timeout)
 
-        # Skip Bambuddy's own virtual printer
-        if serial == VIRTUAL_PRINTER_SERIAL:
+        # Skip Bambuddy's own virtual printer (any model variant)
+        if serial and serial.endswith(VIRTUAL_PRINTER_SERIAL_SUFFIX):
             logger.debug(f"Ignoring Bambuddy virtual printer at {ip}")
             return
 

+ 382 - 0
backend/app/services/firmware_check.py

@@ -0,0 +1,382 @@
+"""
+Firmware Check Service
+
+Checks for firmware updates by fetching from Bambu Lab's official firmware download page.
+Also provides firmware download functionality for offline updates.
+"""
+
+import logging
+import re
+import time
+from collections.abc import Callable
+from dataclasses import dataclass
+from pathlib import Path
+
+import httpx
+
+from backend.app.core.config import _data_dir
+
+logger = logging.getLogger(__name__)
+
+# Bambu Lab firmware download page
+BAMBU_FIRMWARE_BASE = "https://bambulab.com"
+FIRMWARE_PAGE = "/en/support/firmware-download/all"
+
+# Cache TTL in seconds (1 hour)
+CACHE_TTL = 3600
+
+# Map Bambuddy model names to Bambu Lab API keys
+MODEL_TO_API_KEY = {
+    "X1": "x1",
+    "X1C": "x1",
+    "X1-Carbon": "x1",
+    "X1 Carbon": "x1",
+    "P1P": "p1",
+    "P1S": "p1",
+    "A1": "a1",
+    "A1 Mini": "a1-mini",
+    "A1-Mini": "a1-mini",
+    "A1mini": "a1-mini",
+    "H2D": "h2d",
+    "H2C": "h2d",  # H2C uses same firmware as H2D
+    "H2S": "h2s",
+    "P2S": "p2s",
+    "X1E": "x1e",
+    "H2D Pro": "h2d-pro",
+    "H2D-Pro": "h2d-pro",
+}
+
+# Reverse mapping: API key to model codes
+API_KEY_TO_DEV_MODEL = {
+    "x1": "BL-P001",
+    "p1": "C11",
+    "a1": "N2S",
+    "a1-mini": "N1",
+    "h2d": "O1D",
+    "h2s": "O1S",
+    "p2s": "N7",
+    "x1e": "C13",
+    "h2d-pro": "O1E",
+}
+
+
+@dataclass
+class FirmwareVersion:
+    """Firmware version information."""
+
+    version: str
+    download_url: str
+    release_notes: str | None = None
+    release_time: str | None = None
+
+
+class FirmwareCheckService:
+    """Service for checking firmware updates from Bambu Lab."""
+
+    def __init__(self):
+        self._build_id: str | None = None
+        self._build_id_time: float = 0
+        self._version_cache: dict[str, FirmwareVersion] = {}
+        self._cache_time: float = 0
+        self._client = httpx.AsyncClient(
+            timeout=30.0,
+            headers={
+                "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
+            },
+        )
+
+    async def _get_build_id(self) -> str | None:
+        """Fetch the Next.js build ID from Bambu Lab's firmware page."""
+        # Use cached build ID if still valid (cache for 1 hour)
+        if self._build_id and (time.time() - self._build_id_time) < CACHE_TTL:
+            return self._build_id
+
+        try:
+            response = await self._client.get(f"{BAMBU_FIRMWARE_BASE}{FIRMWARE_PAGE}")
+            if response.status_code == 200:
+                # Extract buildId from the page
+                match = re.search(r'"buildId":"([^"]+)"', response.text)
+                if match:
+                    self._build_id = match.group(1)
+                    self._build_id_time = time.time()
+                    logger.info(f"Got Bambu Lab build ID: {self._build_id}")
+                    return self._build_id
+            logger.warning(f"Failed to get Bambu Lab page: {response.status_code}")
+        except Exception as e:
+            logger.error(f"Error fetching Bambu Lab build ID: {e}")
+
+        return self._build_id  # Return cached value if available
+
+    async def _fetch_firmware_versions(self, api_key: str) -> FirmwareVersion | None:
+        """Fetch firmware versions for a specific printer from Bambu Lab API."""
+        build_id = await self._get_build_id()
+        if not build_id:
+            logger.warning("No build ID available, cannot fetch firmware versions")
+            return None
+
+        try:
+            url = f"{BAMBU_FIRMWARE_BASE}/_next/data/{build_id}/en/support/firmware-download/{api_key}.json"
+            response = await self._client.get(url)
+
+            if response.status_code == 200:
+                data = response.json()
+                page_props = data.get("pageProps", {})
+                printer_map = page_props.get("printerMap", {})
+                printer_data = printer_map.get(api_key, {})
+                versions = printer_data.get("versions", [])
+
+                if versions:
+                    latest = versions[0]
+                    return FirmwareVersion(
+                        version=latest.get("version", ""),
+                        download_url=latest.get("url", ""),
+                        release_notes=latest.get("release_notes_en"),
+                        release_time=latest.get("release_time"),
+                    )
+            else:
+                logger.warning(f"Failed to fetch firmware for {api_key}: {response.status_code}")
+
+        except Exception as e:
+            logger.error(f"Error fetching firmware for {api_key}: {e}")
+
+        return None
+
+    async def get_latest_version(self, model: str) -> FirmwareVersion | None:
+        """
+        Get the latest firmware version for a printer model.
+
+        Args:
+            model: Bambuddy printer model name (e.g., "X1C", "P1S", "H2D")
+
+        Returns:
+            FirmwareVersion if found, None otherwise
+        """
+        # Normalize model name
+        model_upper = model.upper().replace(" ", "").replace("-", "")
+
+        # Find the API key for this model
+        api_key = None
+        for model_name, key in MODEL_TO_API_KEY.items():
+            if model_name.upper().replace(" ", "").replace("-", "") == model_upper:
+                api_key = key
+                break
+
+        if not api_key:
+            # Try direct lookup with original model
+            api_key = MODEL_TO_API_KEY.get(model)
+
+        if not api_key:
+            logger.debug(f"Unknown printer model: {model}")
+            return None
+
+        # Check cache
+        cache_key = api_key
+        if cache_key in self._version_cache and (time.time() - self._cache_time) < CACHE_TTL:
+            return self._version_cache[cache_key]
+
+        # Fetch from API
+        version = await self._fetch_firmware_versions(api_key)
+        if version:
+            self._version_cache[cache_key] = version
+            self._cache_time = time.time()
+
+        return version
+
+    async def check_for_update(self, model: str, current_version: str) -> dict:
+        """
+        Check if a firmware update is available for a printer.
+
+        Args:
+            model: Printer model name
+            current_version: Currently installed firmware version
+
+        Returns:
+            Dict with update info:
+            - update_available: bool
+            - current_version: str
+            - latest_version: str or None
+            - download_url: str or None
+            - release_notes: str or None
+        """
+        result = {
+            "update_available": False,
+            "current_version": current_version,
+            "latest_version": None,
+            "download_url": None,
+            "release_notes": None,
+        }
+
+        if not current_version:
+            return result
+
+        latest = await self.get_latest_version(model)
+        if not latest:
+            return result
+
+        result["latest_version"] = latest.version
+        result["download_url"] = latest.download_url
+        result["release_notes"] = latest.release_notes
+
+        # Compare versions (format: XX.XX.XX.XX)
+        try:
+            current_parts = [int(x) for x in current_version.split(".")]
+            latest_parts = [int(x) for x in latest.version.split(".")]
+
+            # Pad to same length
+            while len(current_parts) < 4:
+                current_parts.append(0)
+            while len(latest_parts) < 4:
+                latest_parts.append(0)
+
+            result["update_available"] = latest_parts > current_parts
+        except (ValueError, AttributeError):
+            logger.warning(f"Could not compare versions: {current_version} vs {latest.version}")
+
+        return result
+
+    async def get_all_latest_versions(self) -> dict[str, FirmwareVersion]:
+        """
+        Fetch latest firmware versions for all known printer models.
+
+        Returns:
+            Dict mapping API key to FirmwareVersion
+        """
+        results = {}
+
+        for api_key in API_KEY_TO_DEV_MODEL:
+            version = await self._fetch_firmware_versions(api_key)
+            if version:
+                results[api_key] = version
+
+        return results
+
+    def _get_firmware_cache_dir(self) -> Path:
+        """Get the firmware cache directory, creating it if needed."""
+        cache_dir = _data_dir / "firmware"
+        cache_dir.mkdir(parents=True, exist_ok=True)
+        return cache_dir
+
+    def _get_cached_firmware_path(self, model: str, version: str) -> Path:
+        """Get the path where a firmware file would be cached."""
+        # Normalize model name for filename
+        model_safe = model.upper().replace(" ", "-").replace("/", "-")
+        version_safe = version.replace(".", "_")
+        filename = f"{model_safe}_{version_safe}.bin"
+        return self._get_firmware_cache_dir() / filename
+
+    async def get_firmware_file_info(self, model: str) -> dict | None:
+        """
+        Get information about the firmware file for a model.
+
+        Returns:
+            Dict with download_url, version, filename, and estimated_size (if available)
+        """
+        latest = await self.get_latest_version(model)
+        if not latest or not latest.download_url:
+            return None
+
+        # Extract filename from URL
+        url_parts = latest.download_url.split("/")
+        filename = url_parts[-1] if url_parts else f"firmware_{model}.bin"
+
+        return {
+            "download_url": latest.download_url,
+            "version": latest.version,
+            "filename": filename,
+            "release_notes": latest.release_notes,
+        }
+
+    async def download_firmware(
+        self,
+        model: str,
+        progress_callback: Callable[[int, int, str], None] | None = None,
+    ) -> Path | None:
+        """
+        Download firmware file for a printer model.
+
+        Args:
+            model: Printer model name (e.g., "X1C", "P1S", "H2D")
+            progress_callback: Optional callback(bytes_downloaded, total_bytes, status_message)
+
+        Returns:
+            Path to downloaded firmware file, or None on failure
+        """
+        latest = await self.get_latest_version(model)
+        if not latest or not latest.download_url:
+            logger.warning(f"No firmware download URL available for model: {model}")
+            return None
+
+        # Check if already cached
+        cached_path = self._get_cached_firmware_path(model, latest.version)
+        if cached_path.exists():
+            logger.info(f"Using cached firmware: {cached_path}")
+            return cached_path
+
+        # Extract original filename from URL (must preserve for SD card update)
+        url_parts = latest.download_url.split("/")
+        original_filename = url_parts[-1] if url_parts else f"firmware_{model}.bin"
+
+        # Download to temp file first
+        temp_path = self._get_firmware_cache_dir() / f".downloading_{original_filename}"
+
+        try:
+            logger.info(f"Downloading firmware from {latest.download_url}")
+            if progress_callback:
+                progress_callback(0, 0, "Starting download...")
+
+            async with self._client.stream("GET", latest.download_url) as response:
+                if response.status_code != 200:
+                    logger.error(f"Firmware download failed with status {response.status_code}")
+                    return None
+
+                total_size = int(response.headers.get("content-length", 0))
+                downloaded = 0
+
+                with open(temp_path, "wb") as f:
+                    async for chunk in response.aiter_bytes(chunk_size=65536):
+                        f.write(chunk)
+                        downloaded += len(chunk)
+                        if progress_callback:
+                            progress_callback(downloaded, total_size, "Downloading firmware...")
+
+            # Also save a copy with the original filename for SD card
+            original_path = self._get_firmware_cache_dir() / original_filename
+            if original_path.exists():
+                original_path.unlink()
+
+            # Move temp to both cached path and original filename path
+            import shutil
+
+            shutil.copy2(temp_path, cached_path)
+            temp_path.rename(original_path)
+
+            logger.info(f"Firmware downloaded successfully: {original_path}")
+            if progress_callback:
+                progress_callback(downloaded, total_size, "Download complete")
+
+            return original_path
+
+        except Exception as e:
+            logger.error(f"Firmware download failed: {e}")
+            if temp_path.exists():
+                try:
+                    temp_path.unlink()
+                except Exception:
+                    pass
+            return None
+
+    async def close(self):
+        """Close the HTTP client."""
+        await self._client.aclose()
+
+
+# Singleton instance
+_firmware_service: FirmwareCheckService | None = None
+
+
+def get_firmware_service() -> FirmwareCheckService:
+    """Get the singleton firmware check service instance."""
+    global _firmware_service
+    if _firmware_service is None:
+        _firmware_service = FirmwareCheckService()
+    return _firmware_service

+ 378 - 0
backend/app/services/firmware_update.py

@@ -0,0 +1,378 @@
+"""
+Firmware Update Service
+
+Orchestrates firmware updates for Bambu Lab printers:
+1. Check prerequisites (SD card, space, update available)
+2. Download firmware from Bambu Lab
+3. Upload to printer's SD card via FTP
+4. Notify user to trigger update from printer screen
+"""
+
+import asyncio
+import logging
+from dataclasses import dataclass
+from enum import Enum
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.websocket import ws_manager
+from backend.app.models.printer import Printer
+from backend.app.services.bambu_ftp import (
+    get_ftp_retry_settings,
+    get_storage_info_async,
+    upload_file_async,
+    with_ftp_retry,
+)
+from backend.app.services.firmware_check import get_firmware_service
+from backend.app.services.printer_manager import printer_manager
+
+logger = logging.getLogger(__name__)
+
+
+class FirmwareUploadStatus(str, Enum):
+    """Status of a firmware upload operation."""
+
+    IDLE = "idle"
+    PREPARING = "preparing"
+    DOWNLOADING = "downloading"
+    UPLOADING = "uploading"
+    COMPLETE = "complete"
+    ERROR = "error"
+
+
+@dataclass
+class FirmwareUploadState:
+    """State of a firmware upload operation for a printer."""
+
+    status: FirmwareUploadStatus = FirmwareUploadStatus.IDLE
+    progress: int = 0  # 0-100
+    message: str = ""
+    error: str | None = None
+    firmware_filename: str | None = None
+    firmware_version: str | None = None
+
+
+# Track upload state per printer
+_upload_states: dict[int, FirmwareUploadState] = {}
+
+
+def get_upload_state(printer_id: int) -> FirmwareUploadState:
+    """Get the current upload state for a printer."""
+    if printer_id not in _upload_states:
+        _upload_states[printer_id] = FirmwareUploadState()
+    return _upload_states[printer_id]
+
+
+def reset_upload_state(printer_id: int):
+    """Reset the upload state for a printer."""
+    _upload_states[printer_id] = FirmwareUploadState()
+
+
+class FirmwareUpdateService:
+    """Service for managing firmware updates."""
+
+    # Minimum free space required (100MB buffer)
+    MIN_FREE_SPACE_BYTES = 100 * 1024 * 1024
+
+    async def prepare_update(
+        self,
+        printer_id: int,
+        db: AsyncSession,
+    ) -> dict:
+        """
+        Check prerequisites for firmware update.
+
+        Returns:
+            Dict with:
+            - can_proceed: bool
+            - sd_card_present: bool
+            - sd_card_free_space: int (bytes, -1 if unknown)
+            - firmware_size: int (bytes, estimated)
+            - space_sufficient: bool
+            - update_available: bool
+            - current_version: str | None
+            - latest_version: str | None
+            - firmware_filename: str | None
+            - errors: list[str]
+        """
+        result = {
+            "can_proceed": False,
+            "sd_card_present": False,
+            "sd_card_free_space": -1,
+            "firmware_size": 0,
+            "space_sufficient": False,
+            "update_available": False,
+            "current_version": None,
+            "latest_version": None,
+            "firmware_filename": None,
+            "errors": [],
+        }
+
+        # Get printer from database
+        stmt = select(Printer).where(Printer.id == printer_id)
+        db_result = await db.execute(stmt)
+        printer = db_result.scalar_one_or_none()
+
+        if not printer:
+            result["errors"].append("Printer not found")
+            return result
+
+        # Check printer is connected
+        mqtt_client = printer_manager.get_client(printer_id)
+        if not mqtt_client or not mqtt_client.state:
+            result["errors"].append("Printer not connected")
+            return result
+
+        state = mqtt_client.state
+
+        # Get current firmware version
+        result["current_version"] = state.firmware_version
+
+        # Check SD card
+        result["sd_card_present"] = state.sdcard
+        if not state.sdcard:
+            result["errors"].append("No SD card inserted in printer")
+
+        # Get storage info via FTP
+        if state.sdcard:
+            try:
+                storage_info = await get_storage_info_async(
+                    printer.ip_address,
+                    printer.access_code,
+                )
+                if storage_info and "free_bytes" in storage_info:
+                    result["sd_card_free_space"] = storage_info["free_bytes"]
+            except Exception as e:
+                logger.warning(f"Could not get storage info: {e}")
+
+        # Check for firmware update
+        firmware_service = get_firmware_service()
+        model = printer.model or "Unknown"
+
+        if state.firmware_version:
+            update_info = await firmware_service.check_for_update(model, state.firmware_version)
+            result["update_available"] = update_info["update_available"]
+            result["latest_version"] = update_info["latest_version"]
+        else:
+            # If we don't know current version, just get latest
+            latest = await firmware_service.get_latest_version(model)
+            if latest:
+                result["latest_version"] = latest.version
+                result["update_available"] = True  # Assume update needed
+
+        if not result["update_available"]:
+            result["errors"].append("Firmware is already up to date")
+
+        # Get firmware file info
+        file_info = await firmware_service.get_firmware_file_info(model)
+        if file_info:
+            result["firmware_filename"] = file_info["filename"]
+            # Estimate size (typical firmware is 50-150MB)
+            # We'll get actual size during download
+            result["firmware_size"] = 100 * 1024 * 1024  # 100MB estimate
+
+        # Check space
+        if result["sd_card_free_space"] > 0:
+            # Need firmware size + buffer
+            required = result["firmware_size"] + self.MIN_FREE_SPACE_BYTES
+            result["space_sufficient"] = result["sd_card_free_space"] >= required
+            if not result["space_sufficient"]:
+                result["errors"].append(
+                    f"Insufficient SD card space. Need {required // (1024 * 1024)}MB, "
+                    f"have {result['sd_card_free_space'] // (1024 * 1024)}MB"
+                )
+        elif result["sd_card_present"]:
+            # Couldn't determine space, assume sufficient
+            result["space_sufficient"] = True
+
+        # Final check
+        result["can_proceed"] = (
+            result["sd_card_present"]
+            and result["space_sufficient"]
+            and result["update_available"]
+            and len(result["errors"]) == 0
+        )
+
+        return result
+
+    async def start_upload(
+        self,
+        printer_id: int,
+        db: AsyncSession,
+    ) -> bool:
+        """
+        Start the firmware upload process.
+
+        This runs asynchronously and broadcasts progress via WebSocket.
+        Returns True if upload started successfully.
+        """
+        state = get_upload_state(printer_id)
+
+        # Check if already in progress
+        if state.status in (FirmwareUploadStatus.DOWNLOADING, FirmwareUploadStatus.UPLOADING):
+            logger.warning(f"Firmware upload already in progress for printer {printer_id}")
+            return False
+
+        # Get printer
+        stmt = select(Printer).where(Printer.id == printer_id)
+        db_result = await db.execute(stmt)
+        printer = db_result.scalar_one_or_none()
+
+        if not printer:
+            state.status = FirmwareUploadStatus.ERROR
+            state.error = "Printer not found"
+            return False
+
+        # Get printer model
+        model = printer.model or "Unknown"
+
+        # Reset state
+        reset_upload_state(printer_id)
+        state = get_upload_state(printer_id)
+        state.status = FirmwareUploadStatus.PREPARING
+        state.message = "Preparing firmware update..."
+        await self._broadcast_progress(printer_id, state)
+
+        # Run the upload in background
+        asyncio.create_task(
+            self._do_upload(
+                printer_id=printer_id,
+                ip_address=printer.ip_address,
+                access_code=printer.access_code,
+                model=model,
+            )
+        )
+
+        return True
+
+    async def _do_upload(
+        self,
+        printer_id: int,
+        ip_address: str,
+        access_code: str,
+        model: str,
+    ):
+        """Perform the actual firmware download and upload."""
+        state = get_upload_state(printer_id)
+        firmware_service = get_firmware_service()
+
+        try:
+            # Download firmware (quick, usually cached)
+            state.status = FirmwareUploadStatus.DOWNLOADING
+            state.progress = 0
+            state.message = "Preparing firmware..."
+            await self._broadcast_progress(printer_id, state)
+
+            firmware_path = await firmware_service.download_firmware(model)
+
+            if not firmware_path:
+                raise Exception("Failed to download firmware")
+
+            state.firmware_filename = firmware_path.name
+
+            # Get firmware version for state
+            latest = await firmware_service.get_latest_version(model)
+            if latest:
+                state.firmware_version = latest.version
+
+            # Upload to printer (0-100% progress shown here)
+            state.status = FirmwareUploadStatus.UPLOADING
+            state.progress = 0
+            state.message = f"Uploading {firmware_path.name} to printer..."
+            await self._broadcast_progress(printer_id, state)
+
+            # Upload to root of SD card (where printer expects firmware)
+            remote_path = f"/{firmware_path.name}"
+
+            logger.info(f"Uploading firmware to printer {printer_id}: {remote_path}")
+
+            # Track real progress via FTP callback
+            loop = asyncio.get_event_loop()
+            last_progress = 0
+
+            def on_upload_progress(uploaded: int, total: int):
+                nonlocal last_progress
+                if total > 0:
+                    progress = int((uploaded / total) * 100)
+                    # Only broadcast every 1% to avoid flooding
+                    if progress > last_progress:
+                        last_progress = progress
+                        state.progress = min(99, progress)  # Cap at 99 until complete
+                        asyncio.run_coroutine_threadsafe(self._broadcast_progress(printer_id, state), loop)
+
+            # Get FTP retry settings
+            ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
+
+            if ftp_retry_enabled:
+                success = await with_ftp_retry(
+                    upload_file_async,
+                    ip_address,
+                    access_code,
+                    firmware_path,
+                    remote_path,
+                    progress_callback=on_upload_progress,
+                    socket_timeout=ftp_timeout,
+                    printer_model=model,
+                    max_retries=ftp_retry_count,
+                    retry_delay=ftp_retry_delay,
+                    operation_name=f"Upload firmware to printer {printer_id}",
+                )
+            else:
+                success = await upload_file_async(
+                    ip_address,
+                    access_code,
+                    firmware_path,
+                    remote_path,
+                    progress_callback=on_upload_progress,
+                    socket_timeout=ftp_timeout,
+                    printer_model=model,
+                )
+
+            if not success:
+                raise Exception("Failed to upload firmware to printer")
+
+            # Complete
+            state.status = FirmwareUploadStatus.COMPLETE
+            state.progress = 100
+            state.message = (
+                f"Firmware {state.firmware_version or ''} uploaded successfully! "
+                "Please go to printer screen and trigger the update from Settings > Firmware."
+            )
+            await self._broadcast_progress(printer_id, state)
+
+            logger.info(f"Firmware upload complete for printer {printer_id}")
+
+        except Exception as e:
+            logger.error(f"Firmware upload failed for printer {printer_id}: {e}")
+            state.status = FirmwareUploadStatus.ERROR
+            state.error = str(e)
+            state.message = f"Firmware upload failed: {e}"
+            await self._broadcast_progress(printer_id, state)
+
+    async def _broadcast_progress(self, printer_id: int, state: FirmwareUploadState):
+        """Broadcast firmware upload progress via WebSocket."""
+        await ws_manager.broadcast(
+            {
+                "type": "firmware_upload_progress",
+                "printer_id": printer_id,
+                "status": state.status.value,
+                "progress": state.progress,
+                "message": state.message,
+                "error": state.error,
+                "firmware_filename": state.firmware_filename,
+                "firmware_version": state.firmware_version,
+            }
+        )
+
+
+# Singleton instance
+_firmware_update_service: FirmwareUpdateService | None = None
+
+
+def get_firmware_update_service() -> FirmwareUpdateService:
+    """Get the singleton firmware update service instance."""
+    global _firmware_update_service
+    if _firmware_update_service is None:
+        _firmware_update_service = FirmwareUpdateService()
+    return _firmware_update_service

+ 42 - 9
backend/app/services/print_scheduler.py

@@ -13,7 +13,7 @@ from backend.app.models.archive import PrintArchive
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
 from backend.app.models.smart_plug import SmartPlug
-from backend.app.services.bambu_ftp import upload_file_async
+from backend.app.services.bambu_ftp import get_ftp_retry_settings, upload_file_async, with_ftp_retry
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.tasmota import tasmota_service
 
@@ -271,13 +271,32 @@ class PrintScheduler:
         remote_filename = archive.filename
         remote_path = f"/cache/{remote_filename}"
 
+        # Get FTP retry settings
+        ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
+
         try:
-            uploaded = await upload_file_async(
-                printer.ip_address,
-                printer.access_code,
-                file_path,
-                remote_path,
-            )
+            if ftp_retry_enabled:
+                uploaded = await with_ftp_retry(
+                    upload_file_async,
+                    printer.ip_address,
+                    printer.access_code,
+                    file_path,
+                    remote_path,
+                    socket_timeout=ftp_timeout,
+                    printer_model=printer.model,
+                    max_retries=ftp_retry_count,
+                    retry_delay=ftp_retry_delay,
+                    operation_name=f"Upload print to {printer.name}",
+                )
+            else:
+                uploaded = await upload_file_async(
+                    printer.ip_address,
+                    printer.access_code,
+                    file_path,
+                    remote_path,
+                    socket_timeout=ftp_timeout,
+                    printer_model=printer.model,
+                )
         except Exception as e:
             uploaded = False
             logger.error(f"Queue item {item.id}: FTP error: {e}")
@@ -296,8 +315,22 @@ class PrintScheduler:
 
         register_expected_print(item.printer_id, remote_filename, archive.id)
 
-        # Start the print
-        started = printer_manager.start_print(item.printer_id, remote_filename)
+        # Parse AMS mapping if stored
+        ams_mapping = None
+        if item.ams_mapping:
+            try:
+                import json
+
+                ams_mapping = json.loads(item.ams_mapping)
+            except json.JSONDecodeError:
+                logger.warning(f"Queue item {item.id}: Invalid AMS mapping JSON, ignoring")
+
+        # Start the print with AMS mapping if available
+        started = printer_manager.start_print(
+            item.printer_id,
+            remote_filename,
+            ams_mapping=ams_mapping,
+        )
 
         if started:
             item.status = "printing"

+ 24 - 2
backend/app/services/printer_manager.py

@@ -160,10 +160,32 @@ class PrinterManager:
                 if self._on_status_change:
                     self._schedule_async(self._on_status_change(printer_id, client.state))
 
-    def start_print(self, printer_id: int, filename: str, plate_id: int = 1) -> bool:
+    def start_print(
+        self,
+        printer_id: int,
+        filename: str,
+        plate_id: int = 1,
+        ams_mapping: list[int] | None = None,
+        bed_levelling: bool = True,
+        flow_cali: bool = False,
+        vibration_cali: bool = True,
+        layer_inspect: bool = False,
+        timelapse: bool = False,
+        use_ams: bool = True,
+    ) -> bool:
         """Start a print on a connected printer."""
         if printer_id in self._clients:
-            return self._clients[printer_id].start_print(filename, plate_id)
+            return self._clients[printer_id].start_print(
+                filename,
+                plate_id,
+                ams_mapping=ams_mapping,
+                timelapse=timelapse,
+                bed_levelling=bed_levelling,
+                flow_cali=flow_cali,
+                vibration_cali=vibration_cali,
+                layer_inspect=layer_inspect,
+                use_ams=use_ams,
+            )
         return False
 
     def stop_print(self, printer_id: int) -> bool:

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

@@ -327,6 +327,7 @@ class SpoolmanClient:
         spool_id: int,
         remaining_weight: float | None = None,
         location: str | None = None,
+        clear_location: bool = False,
         extra: dict | None = None,
     ) -> dict | None:
         """Update an existing spool in Spoolman.
@@ -334,7 +335,8 @@ class SpoolmanClient:
         Args:
             spool_id: ID of the spool to update
             remaining_weight: New remaining weight in grams
-            location: New location
+            location: New location (ignored if clear_location is True)
+            clear_location: If True, clears the location field
             extra: Extra fields to update
 
         Returns:
@@ -344,7 +346,9 @@ class SpoolmanClient:
             data = {}
             if remaining_weight is not None:
                 data["remaining_weight"] = remaining_weight
-            if location:
+            if clear_location:
+                data["location"] = None
+            elif location:
                 data["location"] = location
             if extra:
                 data["extra"] = extra
@@ -407,6 +411,67 @@ class SpoolmanClient:
                         return spool
         return None
 
+    async def find_spools_by_location_prefix(self, location_prefix: str) -> list[dict]:
+        """Find all spools with locations starting with a given prefix.
+
+        Args:
+            location_prefix: The location prefix to search for (e.g., "PrinterName - ")
+
+        Returns:
+            List of spool dictionaries with matching locations.
+        """
+        spools = await self.get_spools()
+        matching = []
+        for spool in spools:
+            location = spool.get("location", "")
+            if location and location.startswith(location_prefix):
+                matching.append(spool)
+        return matching
+
+    async def clear_location_for_removed_spools(
+        self,
+        printer_name: str,
+        current_tray_uuids: set[str],
+    ) -> int:
+        """Clear location for spools that are no longer in the AMS.
+
+        When a spool is removed from the AMS, its location should be cleared
+        in Spoolman. This method finds all spools with locations for this printer
+        and clears the location for any that are not in the current_tray_uuids set.
+
+        Args:
+            printer_name: The printer name used as location prefix
+            current_tray_uuids: Set of tray_uuids currently in the AMS
+
+        Returns:
+            Number of spools whose location was cleared.
+        """
+        location_prefix = f"{printer_name} - "
+        spools_at_printer = await self.find_spools_by_location_prefix(location_prefix)
+        cleared_count = 0
+
+        for spool in spools_at_printer:
+            # Get the tray_uuid (stored as "tag" in extra field)
+            extra = spool.get("extra", {}) or {}
+            stored_tag = extra.get("tag", "")
+            if stored_tag:
+                # Normalize: strip quotes and uppercase
+                spool_uuid = stored_tag.strip('"').upper()
+            else:
+                spool_uuid = ""
+
+            # If this spool's UUID is not in the current AMS, clear its location
+            if spool_uuid not in current_tray_uuids:
+                logger.info(
+                    f"Clearing location for spool {spool['id']} "
+                    f"(was: {spool.get('location')}, uuid: {spool_uuid[:16] if spool_uuid else 'none'}...)"
+                )
+                result = await self.update_spool(spool_id=spool["id"], clear_location=True)
+                if result:
+                    cleared_count += 1
+
+        return cleared_count
+
     async def ensure_bambu_vendor(self) -> int | None:
         """Ensure Bambu Lab vendor exists and return its ID.
 

+ 15 - 1
backend/app/services/telemetry.py

@@ -6,10 +6,11 @@ import uuid
 from datetime import datetime, timedelta
 
 import httpx
-from sqlalchemy import select
+from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.config import APP_VERSION
+from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
 
 logger = logging.getLogger(__name__)
@@ -63,6 +64,17 @@ async def get_telemetry_url(db: AsyncSession) -> str:
     return setting.value if setting else DEFAULT_TELEMETRY_URL
 
 
+async def get_printer_model_counts(db: AsyncSession) -> dict[str, int]:
+    """Get count of each printer model configured in BamBuddy."""
+    result = await db.execute(select(Printer.model, func.count(Printer.id)).group_by(Printer.model))
+    counts = {}
+    for model, count in result.all():
+        # Normalize model name (handle None/empty)
+        model_name = model if model else "Unknown"
+        counts[model_name] = count
+    return counts
+
+
 async def send_heartbeat(db: AsyncSession) -> bool:
     """Send anonymous heartbeat to telemetry server."""
     global _last_heartbeat
@@ -80,6 +92,7 @@ async def send_heartbeat(db: AsyncSession) -> bool:
 
         installation_id = await get_or_create_installation_id(db)
         telemetry_url = await get_telemetry_url(db)
+        printer_models = await get_printer_model_counts(db)
 
         async with httpx.AsyncClient(timeout=10.0) as client:
             response = await client.post(
@@ -87,6 +100,7 @@ async def send_heartbeat(db: AsyncSession) -> bool:
                 json={
                     "installation_id": installation_id,
                     "version": APP_VERSION,
+                    "printer_models": printer_models,
                 },
             )
             response.raise_for_status()

+ 21 - 0
backend/tests/integration/test_print_queue_api.py

@@ -147,6 +147,27 @@ class TestPrintQueueAPI:
         assert result["status"] == "pending"
         assert result["manual_start"] is True
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_with_ams_mapping(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Verify item can be added to queue with ams_mapping."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+            "ams_mapping": [5, -1, 2, -1],  # Slot 1 -> tray 5, slot 3 -> tray 2
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["printer_id"] == printer.id
+        assert result["archive_id"] == archive.id
+        assert result["ams_mapping"] == [5, -1, 2, -1]
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_get_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):

+ 82 - 0
backend/tests/integration/test_printers_api.py

@@ -711,3 +711,85 @@ class TestSkipObjectsAPI:
             assert 100 in result["skipped_objects"]
             assert 200 in result["skipped_objects"]
             mock_client.skip_objects.assert_called_once_with([100, 200])
+
+
+class TestChamberLightAPI:
+    """Integration tests for chamber light control endpoint."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_chamber_light_not_found(self, async_client: AsyncClient):
+        """Verify 404 for non-existent printer."""
+        response = await async_client.post("/api/v1/printers/99999/chamber-light?on=true")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_chamber_light_not_connected(self, async_client: AsyncClient, printer_factory):
+        """Verify error when printer is not connected."""
+        printer = await printer_factory(name="Disconnected Printer")
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = None
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=true")
+
+            assert response.status_code == 400
+            assert "not connected" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_chamber_light_on_success(self, async_client: AsyncClient, printer_factory):
+        """Verify successful chamber light on request."""
+        printer = await printer_factory(name="Test Printer")
+
+        mock_client = MagicMock()
+        mock_client.set_chamber_light.return_value = True
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=true")
+
+            assert response.status_code == 200
+            result = response.json()
+            assert result["success"] is True
+            assert "on" in result["message"].lower()
+            mock_client.set_chamber_light.assert_called_once_with(True)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_chamber_light_off_success(self, async_client: AsyncClient, printer_factory):
+        """Verify successful chamber light off request."""
+        printer = await printer_factory(name="Test Printer")
+
+        mock_client = MagicMock()
+        mock_client.set_chamber_light.return_value = True
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=false")
+
+            assert response.status_code == 200
+            result = response.json()
+            assert result["success"] is True
+            assert "off" in result["message"].lower()
+            mock_client.set_chamber_light.assert_called_once_with(False)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_chamber_light_failure(self, async_client: AsyncClient, printer_factory):
+        """Verify error handling when chamber light control fails."""
+        printer = await printer_factory(name="Test Printer")
+
+        mock_client = MagicMock()
+        mock_client.set_chamber_light.return_value = False
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=true")
+
+            assert response.status_code == 500
+            assert "failed" in response.json()["detail"].lower()

+ 11 - 1
backend/tests/unit/services/test_printer_manager.py

@@ -364,7 +364,17 @@ class TestPrinterManager:
 
         result = manager.start_print(1, "test.gcode")
 
-        mock_client.start_print.assert_called_once_with("test.gcode", 1)
+        mock_client.start_print.assert_called_once_with(
+            "test.gcode",
+            1,
+            ams_mapping=None,
+            timelapse=False,
+            bed_levelling=True,
+            flow_cali=False,
+            vibration_cali=True,
+            layer_inspect=False,
+            use_ams=True,
+        )
         assert result is True
 
     def test_start_print_returns_false_for_unknown(self, manager):

+ 83 - 0
bambuddy-issue-notes.txt

@@ -0,0 +1,83 @@
+=== BAMBUDDY FILE DELETION ISSUE - Jan 8, 2026 ===
+=== ROOT CAUSE IDENTIFIED ===
+
+WHAT HAPPENED:
+- /opt was COMPLETELY DELETED on TWO containers:
+  - Container 109 (claude): ~11:22 and ~12:22
+  - Container 107 (3dp): ~13:28
+- Container 107 was "untouched" (no SSH, no Claude Code) - just running BamBuddy
+
+ROOT CAUSE FOUND:
+Bug in backend/app/services/archive.py delete_archive() function (lines 914-929):
+
+    file_path = settings.base_dir / archive.file_path
+    if file_path.exists():
+        archive_dir = file_path.parent
+        shutil.rmtree(archive_dir, ignore_errors=True)  # <-- THE BUG
+
+If archive.file_path is EMPTY or MALFORMED:
+- file_path = /opt/bambuddy / "" = /opt/bambuddy
+- archive_dir = file_path.parent = /opt
+- shutil.rmtree("/opt") --> DELETES ENTIRE /opt DIRECTORY!
+
+TRIGGER:
+- User was deleting archives via BamBuddy web UI on container 107 (3dp)
+- One archive had corrupted/empty file_path in database
+- Deleting that archive triggered shutil.rmtree("/opt")
+- This deleted the entire /opt directory including BamBuddy itself
+
+TIMELINE FOR CONTAINER 107 (3dp):
+- 13:28:19 - Normal operation (WebSocket disconnect)
+- 13:28:44 - DELETE /api/v1/archives/* requests failing with 500
+            (database already gone because /opt was deleted)
+- ls -la / shows root directory modified at 13:28
+
+FIX APPLIED (on container 109):
+Safety checks added to delete_archive() in archive.py:
+1. Check if file_path is not empty
+2. Verify archive_dir is inside settings.archive_dir
+3. Ensure archive_dir is at least 2 levels deep
+4. Log error and refuse to delete if checks fail
+
+TO INVESTIGATE AFTER ROLLBACK:
+On container 107, after rolling back to autodaily260108003006:
+
+    # Find corrupted archive records
+    sqlite3 /opt/bambuddy/data/bambuddy.db \
+      "SELECT id, filename, file_path FROM print_archives
+       WHERE file_path = '' OR file_path IS NULL
+       OR file_path NOT LIKE 'archive/%';"
+
+    # Check all file_path values
+    sqlite3 /opt/bambuddy/data/bambuddy.db \
+      "SELECT id, file_path FROM print_archives ORDER BY id;"
+
+CONTAINER 109 (this host):
+- Were you also deleting archives around 11:22 and 12:22?
+- Same bug could have been triggered here too
+
+PROXMOX COMMANDS FOR ROLLBACK:
+    # Container 107 (3dp)
+    pct rollback 107 autodaily260108003006
+    pct start 107
+
+    # Container 109 (claude) - already done via UI
+    # Current snapshot: autodaily260108003004
+
+WHAT TO DO NEXT:
+1. Rollback container 107 to morning snapshot
+2. Run the SQL query above to find corrupted archive
+3. Apply the fix from container 109 to container 107
+4. Understand how the file_path got corrupted in the first place
+
+THE FIX (apply to both containers):
+In backend/app/services/archive.py, the delete_archive function now has:
+- Empty file_path check
+- Path traversal protection (relative_to check)
+- Minimum depth check (must be 2+ levels inside archive dir)
+- Error logging for refused deletions
+
+NOT CLAUDE CODE'S FAULT:
+This was a bug in BamBuddy's own code that was triggered by:
+1. Corrupted database record (unknown how it got corrupted)
+2. User action (deleting archives via web UI)

+ 2 - 2
frontend/public/sw.js

@@ -1,6 +1,6 @@
 // Bambuddy Service Worker
-const CACHE_NAME = 'bambuddy-v4';
-const STATIC_CACHE = 'bambuddy-static-v4';
+const CACHE_NAME = 'bambuddy-v22';
+const STATIC_CACHE = 'bambuddy-static-v22';
 
 // Static assets to cache on install
 const STATIC_ASSETS = [

+ 20 - 21
frontend/src/App.tsx

@@ -36,28 +36,27 @@ function App() {
     <ThemeProvider>
       <ToastProvider>
         <QueryClientProvider client={queryClient}>
-          <WebSocketProvider>
-            <BrowserRouter>
-              <Routes>
-                {/* Camera page - standalone, no layout */}
-                <Route path="/camera/:printerId" element={<CameraPage />} />
+          <BrowserRouter>
+            <Routes>
+              {/* Camera page - standalone, no layout, no WebSocket (doesn't need real-time updates) */}
+              <Route path="/camera/:printerId" element={<CameraPage />} />
 
-                <Route path="/" element={<Layout />}>
-                  <Route index element={<PrintersPage />} />
-                  <Route path="archives" element={<ArchivesPage />} />
-                  <Route path="queue" element={<QueuePage />} />
-                  <Route path="stats" element={<StatsPage />} />
-                  <Route path="profiles" element={<ProfilesPage />} />
-                  <Route path="maintenance" element={<MaintenancePage />} />
-                  <Route path="projects" element={<ProjectsPage />} />
-                  <Route path="projects/:id" element={<ProjectDetailPage />} />
-                  <Route path="settings" element={<SettingsPage />} />
-                  <Route path="system" element={<SystemInfoPage />} />
-                  <Route path="external/:id" element={<ExternalLinkPage />} />
-                </Route>
-              </Routes>
-            </BrowserRouter>
-          </WebSocketProvider>
+              {/* Main app with WebSocket for real-time updates */}
+              <Route element={<WebSocketProvider><Layout /></WebSocketProvider>}>
+                <Route index element={<PrintersPage />} />
+                <Route path="archives" element={<ArchivesPage />} />
+                <Route path="queue" element={<QueuePage />} />
+                <Route path="stats" element={<StatsPage />} />
+                <Route path="profiles" element={<ProfilesPage />} />
+                <Route path="maintenance" element={<MaintenancePage />} />
+                <Route path="projects" element={<ProjectsPage />} />
+                <Route path="projects/:id" element={<ProjectDetailPage />} />
+                <Route path="settings" element={<SettingsPage />} />
+                <Route path="system" element={<SystemInfoPage />} />
+                <Route path="external/:id" element={<ExternalLinkPage />} />
+              </Route>
+            </Routes>
+          </BrowserRouter>
         </QueryClientProvider>
       </ToastProvider>
     </ThemeProvider>

+ 5 - 0
frontend/src/__tests__/pages/SystemInfoPage.test.tsx

@@ -15,6 +15,11 @@ vi.mock('../../api/client', () => ({
     getSettings: vi.fn().mockResolvedValue({}),
     updateSettings: vi.fn().mockResolvedValue({}),
   },
+  supportApi: {
+    getDebugLoggingState: vi.fn().mockResolvedValue({ enabled: false, enabled_at: null, duration_seconds: null }),
+    setDebugLogging: vi.fn().mockResolvedValue({ enabled: true, enabled_at: new Date().toISOString(), duration_seconds: 0 }),
+    downloadSupportBundle: vi.fn().mockResolvedValue(undefined),
+  },
 }));
 
 // Mock system info response

+ 134 - 2
frontend/src/api/client.ts

@@ -204,6 +204,7 @@ export interface Archive {
   source_3mf_path: string | null;
   duplicates: ArchiveDuplicate[] | null;
   duplicate_count: number;
+  object_count: number | null;
   print_name: string | null;
   print_time_seconds: number | null;
   actual_time_seconds: number | null;  // Computed from started_at/completed_at
@@ -563,6 +564,11 @@ export interface AppSettings {
   light_style: 'classic' | 'glow' | 'vibrant';
   light_background: 'neutral' | 'warm' | 'cool';
   light_accent: 'green' | 'teal' | 'blue' | 'orange' | 'purple' | 'red';
+  // FTP retry settings
+  ftp_retry_enabled: boolean;
+  ftp_retry_count: number;
+  ftp_retry_delay: number;
+  ftp_timeout: number;
 }
 
 export type AppSettingsUpdate = Partial<AppSettings>;
@@ -790,6 +796,7 @@ export interface PrintQueueItem {
   require_previous_success: boolean;
   auto_off_after: boolean;
   manual_start: boolean;  // Requires manual trigger to start (staged)
+  ams_mapping: number[] | null;  // AMS slot mapping for multi-color prints
   status: 'pending' | 'printing' | 'completed' | 'failed' | 'skipped' | 'cancelled';
   started_at: string | null;
   completed_at: string | null;
@@ -808,6 +815,7 @@ export interface PrintQueueItemCreate {
   require_previous_success?: boolean;
   auto_off_after?: boolean;
   manual_start?: boolean;  // Requires manual trigger to start (staged)
+  ams_mapping?: number[] | null;  // AMS slot mapping for multi-color prints
 }
 
 export interface PrintQueueItemUpdate {
@@ -817,6 +825,7 @@ export interface PrintQueueItemUpdate {
   require_previous_success?: boolean;
   auto_off_after?: boolean;
   manual_start?: boolean;
+  ams_mapping?: number[];
 }
 
 // MQTT Logging types
@@ -1189,6 +1198,7 @@ export interface MaintenanceType {
   default_interval_hours: number;
   interval_type: 'hours' | 'days';  // "hours" = print hours, "days" = calendar days
   icon: string | null;
+  wiki_url: string | null;  // Documentation link
   is_system: boolean;
   created_at: string;
 }
@@ -1199,15 +1209,18 @@ export interface MaintenanceTypeCreate {
   default_interval_hours?: number;
   interval_type?: 'hours' | 'days';
   icon?: string | null;
+  wiki_url?: string | null;
 }
 
 export interface MaintenanceStatus {
   id: number;
   printer_id: number;
   printer_name: string;
+  printer_model: string | null;
   maintenance_type_id: number;
   maintenance_type_name: string;
   maintenance_type_icon: string | null;
+  maintenance_type_wiki_url: string | null;  // Custom wiki URL from type
   enabled: boolean;
   interval_hours: number;  // For hours type: print hours; for days type: number of days
   interval_type: 'hours' | 'days';
@@ -1224,6 +1237,7 @@ export interface MaintenanceStatus {
 export interface PrinterMaintenanceOverview {
   printer_id: number;
   printer_name: string;
+  printer_model: string | null;
   total_print_hours: number;
   maintenance_items: MaintenanceStatus[];
   due_count: number;
@@ -1322,6 +1336,12 @@ export const api = {
       method: 'POST',
     }),
 
+  // Chamber Light Control
+  setChamberLight: (printerId: number, on: boolean) =>
+    request<{ success: boolean; message: string }>(`/printers/${printerId}/chamber-light?on=${on}`, {
+      method: 'POST',
+    }),
+
   // Skip Objects
   getPrintableObjects: (printerId: number) =>
     request<{
@@ -1702,10 +1722,26 @@ export const api = {
         used_meters: number;
       }>;
     }>(`/archives/${archiveId}/filament-requirements`),
-  reprintArchive: (archiveId: number, printerId: number) =>
+  reprintArchive: (
+    archiveId: number,
+    printerId: number,
+    options?: {
+      ams_mapping?: number[];
+      timelapse?: boolean;
+      bed_levelling?: boolean;
+      flow_cali?: boolean;
+      vibration_cali?: boolean;
+      layer_inspect?: boolean;
+      use_ams?: boolean;
+    }
+  ) =>
     request<{ status: string; printer_id: number; archive_id: number; filename: string }>(
       `/archives/${archiveId}/reprint?printer_id=${printerId}`,
-      { method: 'POST' }
+      {
+        method: 'POST',
+        headers: options ? { 'Content-Type': 'application/json' } : undefined,
+        body: options ? JSON.stringify(options) : undefined,
+      }
     ),
   uploadArchive: async (file: File, printerId?: number): Promise<Archive> => {
     const formData = new FormData();
@@ -2526,3 +2562,99 @@ export const pendingUploadsApi = {
   discardAll: () =>
     request<{ discarded: number }>('/pending-uploads/discard-all', { method: 'DELETE' }),
 };
+
+// Firmware API Types
+export interface FirmwareUpdateInfo {
+  printer_id: number;
+  printer_name: string;
+  model: string | null;
+  current_version: string | null;
+  latest_version: string | null;
+  update_available: boolean;
+  download_url: string | null;
+  release_notes: string | null;
+}
+
+export interface FirmwareUploadPrepare {
+  can_proceed: boolean;
+  sd_card_present: boolean;
+  sd_card_free_space: number;
+  firmware_size: number;
+  space_sufficient: boolean;
+  update_available: boolean;
+  current_version: string | null;
+  latest_version: string | null;
+  firmware_filename: string | null;
+  errors: string[];
+}
+
+export interface FirmwareUploadStatus {
+  status: 'idle' | 'preparing' | 'downloading' | 'uploading' | 'complete' | 'error';
+  progress: number;
+  message: string;
+  error: string | null;
+  firmware_filename: string | null;
+  firmware_version: string | null;
+}
+
+// Firmware API
+export const firmwareApi = {
+  checkUpdates: () =>
+    request<{ updates: FirmwareUpdateInfo[]; updates_available: number }>('/firmware/updates'),
+
+  checkPrinterUpdate: (printerId: number) =>
+    request<FirmwareUpdateInfo>(`/firmware/updates/${printerId}`),
+
+  prepareUpload: (printerId: number) =>
+    request<FirmwareUploadPrepare>(`/firmware/updates/${printerId}/prepare`),
+
+  startUpload: (printerId: number) =>
+    request<{ started: boolean; message: string }>(`/firmware/updates/${printerId}/upload`, {
+      method: 'POST',
+    }),
+
+  getUploadStatus: (printerId: number) =>
+    request<FirmwareUploadStatus>(`/firmware/updates/${printerId}/upload/status`),
+};
+
+// Support types
+export interface DebugLoggingState {
+  enabled: boolean;
+  enabled_at: string | null;
+  duration_seconds: number | null;
+}
+
+// Support API
+export const supportApi = {
+  getDebugLoggingState: () =>
+    request<DebugLoggingState>('/support/debug-logging'),
+
+  setDebugLogging: (enabled: boolean) =>
+    request<DebugLoggingState>('/support/debug-logging', {
+      method: 'POST',
+      body: JSON.stringify({ enabled }),
+    }),
+
+  downloadSupportBundle: async () => {
+    const response = await fetch(`${API_BASE}/support/bundle`);
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      throw new Error(error.detail || `HTTP ${response.status}`);
+    }
+    // Get filename from Content-Disposition header or use default
+    const disposition = response.headers.get('Content-Disposition');
+    const filenameMatch = disposition?.match(/filename=(.+)/);
+    const filename = filenameMatch ? filenameMatch[1] : 'bambuddy-support.zip';
+
+    // Download the blob
+    const blob = await response.blob();
+    const url = window.URL.createObjectURL(blob);
+    const a = document.createElement('a');
+    a.href = url;
+    a.download = filename;
+    document.body.appendChild(a);
+    a.click();
+    document.body.removeChild(a);
+    window.URL.revokeObjectURL(url);
+  },
+};

+ 27 - 9
frontend/src/components/AMSHistoryModal.tsx

@@ -13,6 +13,7 @@ import {
   ReferenceLine,
 } from 'recharts';
 import { api, type AMSHistoryResponse } from '../api/client';
+import { parseUTCDate, applyTimeFormat, type TimeFormat } from '../utils/date';
 import { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
 
@@ -57,6 +58,13 @@ export function AMSHistoryModal({
   const [mode, setMode] = useState<'humidity' | 'temperature'>(initialMode);
   const isDark = themeMode === 'dark';
 
+  const { data: settings } = useQuery({
+    queryKey: ['settings'],
+    queryFn: api.getSettings,
+  });
+
+  const timeFormat: TimeFormat = settings?.time_format || 'system';
+
   // Close on Escape key
   useEffect(() => {
     if (!isOpen) return;
@@ -79,16 +87,20 @@ export function AMSHistoryModal({
   if (!isOpen) return null;
 
   // Format data for chart
-  const chartData = data?.data.map(point => ({
-    time: new Date(point.recorded_at).getTime(),
-    humidity: point.humidity,
-    temperature: point.temperature,
-    timeLabel: new Date(point.recorded_at).toLocaleTimeString([], {
+  const chartData = data?.data.map(point => {
+    const date = parseUTCDate(point.recorded_at) || new Date();
+    const timeOptions: Intl.DateTimeFormatOptions = {
       hour: '2-digit',
       minute: '2-digit',
       ...(hours > 24 ? { day: 'numeric', month: 'short' } : {}),
-    }),
-  })) || [];
+    };
+    return {
+      time: date.getTime(),
+      humidity: point.humidity,
+      temperature: point.temperature,
+      timeLabel: date.toLocaleTimeString([], applyTimeFormat(timeOptions, timeFormat)),
+    };
+  }) || [];
 
   // Get thresholds
   const humidityGood = thresholds?.humidityGood || 40;
@@ -307,7 +319,7 @@ export function AMSHistoryModal({
                       if (hours > 24) {
                         return date.toLocaleDateString([], { day: 'numeric', month: 'short' });
                       }
-                      return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+                      return date.toLocaleTimeString([], applyTimeFormat({ hour: '2-digit', minute: '2-digit' }, timeFormat));
                     }}
                     stroke={isDark ? '#9ca3af' : '#6b7280'}
                     tick={{ fontSize: 12 }}
@@ -325,7 +337,13 @@ export function AMSHistoryModal({
                       borderRadius: '8px',
                       color: isDark ? '#fff' : '#000',
                     }}
-                    labelFormatter={(ts) => new Date(ts).toLocaleString()}
+                    labelFormatter={(ts) => new Date(ts).toLocaleString(undefined, applyTimeFormat({
+                      year: 'numeric',
+                      month: 'short',
+                      day: 'numeric',
+                      hour: '2-digit',
+                      minute: '2-digit',
+                    }, timeFormat))}
                     formatter={(value: number) => [
                       mode === 'humidity' ? `${value}%` : `${value}°C`,
                       mode === 'humidity' ? 'Humidity' : 'Temperature'

+ 345 - 3
frontend/src/components/AddToQueueModal.tsx

@@ -1,11 +1,12 @@
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useMemo } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Calendar, Clock, X, AlertCircle, Power, Hand } from 'lucide-react';
+import { Calendar, Clock, X, AlertCircle, Power, Hand, Check, AlertTriangle, Circle, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
 import { api } from '../api/client';
 import type { PrintQueueItemCreate } from '../api/client';
 import { Card, CardContent } from './Card';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
+import { getColorName } from '../utils/colors';
 
 interface AddToQueueModalProps {
   archiveId: number;
@@ -22,12 +23,29 @@ export function AddToQueueModal({ archiveId, archiveName, onClose }: AddToQueueM
   const [scheduledTime, setScheduledTime] = useState('');
   const [requirePreviousSuccess, setRequirePreviousSuccess] = useState(false);
   const [autoOffAfter, setAutoOffAfter] = useState(false);
+  const [showFilamentMapping, setShowFilamentMapping] = useState(false);
+  const [isRefreshing, setIsRefreshing] = useState(false);
+  // Manual slot overrides: slot_id (1-indexed) -> globalTrayId
+  const [manualMappings, setManualMappings] = useState<Record<number, number>>({});
 
   const { data: printers } = useQuery({
     queryKey: ['printers'],
     queryFn: () => api.getPrinters(),
   });
 
+  // Fetch filament requirements from the archived 3MF
+  const { data: filamentReqs } = useQuery({
+    queryKey: ['archive-filaments', archiveId],
+    queryFn: () => api.getArchiveFilamentRequirements(archiveId),
+  });
+
+  // Fetch printer status when a printer is selected
+  const { data: printerStatus } = useQuery({
+    queryKey: ['printer-status', printerId],
+    queryFn: () => api.getPrinterStatus(printerId!),
+    enabled: !!printerId,
+  });
+
   // Set default printer if only one available
   useEffect(() => {
     if (printers?.length === 1 && !printerId) {
@@ -35,6 +53,11 @@ export function AddToQueueModal({ archiveId, archiveName, onClose }: AddToQueueM
     }
   }, [printers, printerId]);
 
+  // Clear manual mappings when printer changes
+  useEffect(() => {
+    setManualMappings({});
+  }, [printerId]);
+
   // Close on Escape key
   useEffect(() => {
     const handleKeyDown = (e: KeyboardEvent) => {
@@ -44,6 +67,207 @@ export function AddToQueueModal({ archiveId, archiveName, onClose }: AddToQueueM
     return () => window.removeEventListener('keydown', handleKeyDown);
   }, [onClose]);
 
+  // Helper to normalize color format (API returns "RRGGBBAA", 3MF uses "#RRGGBB")
+  const normalizeColor = (color: string | null | undefined): string => {
+    if (!color) return '#808080';
+    const hex = color.replace('#', '').substring(0, 6);
+    return `#${hex}`;
+  };
+
+  // Helper to format slot label for display
+  const formatSlotLabel = (amsId: number, trayId: number, isHt: boolean, isExternal: boolean): string => {
+    if (isExternal) return 'External';
+    const letter = String.fromCharCode(65 + (amsId >= 128 ? amsId - 128 : amsId));
+    if (isHt) return `HT-${letter}`;
+    return `AMS-${letter} Slot ${trayId + 1}`;
+  };
+
+  // Calculate global tray ID for MQTT command
+  const getGlobalTrayId = (amsId: number, trayId: number, isExternal: boolean): number => {
+    if (isExternal) return 254;
+    return amsId * 4 + trayId;
+  };
+
+  // Build a list of all loaded filaments from printer's AMS/HT/External
+  const loadedFilaments = useMemo(() => {
+    const filaments: Array<{
+      type: string;
+      color: string;
+      colorName: string;
+      amsId: number;
+      trayId: number;
+      isHt: boolean;
+      isExternal: boolean;
+      label: string;
+      globalTrayId: number;
+    }> = [];
+
+    printerStatus?.ams?.forEach((amsUnit) => {
+      const isHt = amsUnit.tray.length === 1;
+      amsUnit.tray.forEach((tray) => {
+        if (tray.tray_type) {
+          const color = normalizeColor(tray.tray_color);
+          filaments.push({
+            type: tray.tray_type,
+            color,
+            colorName: getColorName(color),
+            amsId: amsUnit.id,
+            trayId: tray.id,
+            isHt,
+            isExternal: false,
+            label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
+            globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
+          });
+        }
+      });
+    });
+
+    if (printerStatus?.vt_tray?.tray_type) {
+      const color = normalizeColor(printerStatus.vt_tray.tray_color);
+      filaments.push({
+        type: printerStatus.vt_tray.tray_type,
+        color,
+        colorName: getColorName(color),
+        amsId: -1,
+        trayId: 0,
+        isHt: false,
+        isExternal: true,
+        label: 'External',
+        globalTrayId: 254,
+      });
+    }
+
+    return filaments;
+  }, [printerStatus]);
+
+  // Compare required filaments with loaded filaments
+  const filamentComparison = useMemo(() => {
+    if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return [];
+
+    const normalizeColorForCompare = (color: string | undefined): string => {
+      if (!color) return '';
+      return color.replace('#', '').toLowerCase().substring(0, 6);
+    };
+
+    const colorsAreSimilar = (color1: string | undefined, color2: string | undefined, threshold = 40): boolean => {
+      const hex1 = normalizeColorForCompare(color1);
+      const hex2 = normalizeColorForCompare(color2);
+      if (!hex1 || !hex2 || hex1.length < 6 || hex2.length < 6) return false;
+
+      const r1 = parseInt(hex1.substring(0, 2), 16);
+      const g1 = parseInt(hex1.substring(2, 4), 16);
+      const b1 = parseInt(hex1.substring(4, 6), 16);
+      const r2 = parseInt(hex2.substring(0, 2), 16);
+      const g2 = parseInt(hex2.substring(2, 4), 16);
+      const b2 = parseInt(hex2.substring(4, 6), 16);
+
+      return Math.abs(r1 - r2) <= threshold &&
+             Math.abs(g1 - g2) <= threshold &&
+             Math.abs(b1 - b2) <= threshold;
+    };
+
+    const usedTrayIds = new Set<number>(Object.values(manualMappings));
+
+    return filamentReqs.filaments.map((req) => {
+      const slotId = req.slot_id || 0;
+
+      // Check if there's a manual override for this slot
+      if (slotId > 0 && manualMappings[slotId] !== undefined) {
+        const manualTrayId = manualMappings[slotId];
+        const manualLoaded = loadedFilaments.find((f) => f.globalTrayId === manualTrayId);
+
+        if (manualLoaded) {
+          const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();
+          const colorMatch = normalizeColorForCompare(manualLoaded.color) === normalizeColorForCompare(req.color) ||
+                            colorsAreSimilar(manualLoaded.color, req.color);
+
+          let status: 'match' | 'type_only' | 'mismatch' | 'empty';
+          if (typeMatch && colorMatch) {
+            status = 'match';
+          } else if (typeMatch) {
+            status = 'type_only';
+          } else {
+            status = 'mismatch';
+          }
+
+          return {
+            ...req,
+            loaded: manualLoaded,
+            hasFilament: true,
+            typeMatch,
+            colorMatch,
+            status,
+            isManual: true,
+          };
+        }
+      }
+
+      // Auto-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(
+        (f) => !usedTrayIds.has(f.globalTrayId) &&
+               f.type?.toUpperCase() === req.type?.toUpperCase() &&
+               colorsAreSimilar(f.color, req.color)
+      );
+      const typeOnlyMatch = !exactMatch && !similarMatch && loadedFilaments.find(
+        (f) => !usedTrayIds.has(f.globalTrayId) &&
+               f.type?.toUpperCase() === req.type?.toUpperCase()
+      );
+      const loaded = exactMatch || similarMatch || typeOnlyMatch || undefined;
+
+      if (loaded) {
+        usedTrayIds.add(loaded.globalTrayId);
+      }
+
+      const hasFilament = !!loaded;
+      const typeMatch = hasFilament;
+      const colorMatch = !!exactMatch || !!similarMatch;
+
+      let status: 'match' | 'type_only' | 'mismatch' | 'empty';
+      if (exactMatch || similarMatch) {
+        status = 'match';
+      } else if (typeOnlyMatch) {
+        status = 'type_only';
+      } else {
+        status = 'mismatch';
+      }
+
+      return {
+        ...req,
+        loaded,
+        hasFilament,
+        typeMatch,
+        colorMatch,
+        status,
+        isManual: false,
+      };
+    });
+  }, [filamentReqs, loadedFilaments, manualMappings]);
+
+  // Build AMS mapping array
+  const amsMapping = useMemo(() => {
+    if (filamentComparison.length === 0) return undefined;
+
+    const maxSlotId = Math.max(...filamentComparison.map((f) => f.slot_id || 0));
+    if (maxSlotId <= 0) return undefined;
+
+    const mapping = new Array(maxSlotId).fill(-1);
+
+    filamentComparison.forEach((f) => {
+      if (f.slot_id && f.slot_id > 0) {
+        mapping[f.slot_id - 1] = f.loaded?.globalTrayId ?? -1;
+      }
+    });
+
+    return mapping;
+  }, [filamentComparison]);
+
+  const hasFilamentReqs = filamentReqs?.filaments && filamentReqs.filaments.length > 0;
+
   const addMutation = useMutation({
     mutationFn: (data: PrintQueueItemCreate) => api.addToQueue(data),
     onSuccess: () => {
@@ -69,6 +293,7 @@ export function AddToQueueModal({ archiveId, archiveName, onClose }: AddToQueueM
       require_previous_success: requirePreviousSuccess,
       auto_off_after: autoOffAfter,
       manual_start: scheduleType === 'manual',
+      ams_mapping: amsMapping,
     };
 
     if (scheduleType === 'scheduled' && scheduledTime) {
@@ -90,7 +315,7 @@ export function AddToQueueModal({ archiveId, archiveName, onClose }: AddToQueueM
       className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
       onClick={onClose}
     >
-      <Card className="w-full max-w-md" onClick={(e) => e.stopPropagation()}>
+      <Card className="w-full max-w-lg max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
         <CardContent className="p-0">
           {/* Header */}
           <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
@@ -137,6 +362,123 @@ export function AddToQueueModal({ archiveId, archiveName, onClose }: AddToQueueM
               )}
             </div>
 
+            {/* Filament Mapping Section */}
+            {printerId && hasFilamentReqs && (
+              <div>
+                <button
+                  type="button"
+                  onClick={() => setShowFilamentMapping(!showFilamentMapping)}
+                  className="flex items-center gap-2 text-sm text-bambu-gray hover:text-white transition-colors w-full"
+                >
+                  <Circle className="w-4 h-4" fill={filamentComparison.some(f => f.status === 'mismatch') ? '#f97316' : filamentComparison.some(f => f.status === 'type_only') ? '#facc15' : '#00ae42'} stroke="none" />
+                  <span>Filament Mapping</span>
+                  {filamentComparison.some(f => f.status === 'mismatch') ? (
+                    <span className="text-xs text-orange-400">(Type not found)</span>
+                  ) : filamentComparison.some(f => f.status === 'type_only') ? (
+                    <span className="text-xs text-yellow-400">(Color mismatch)</span>
+                  ) : (
+                    <span className="text-xs text-bambu-green">(Ready)</span>
+                  )}
+                  {showFilamentMapping ? <ChevronUp className="w-4 h-4 ml-auto" /> : <ChevronDown className="w-4 h-4 ml-auto" />}
+                </button>
+
+                {showFilamentMapping && (
+                  <div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
+                    <div className="flex items-center justify-between mb-2">
+                      <span className="text-xs text-bambu-gray">Click to change slot assignment</span>
+                      <button
+                        type="button"
+                        onClick={async () => {
+                          if (!printerId) return;
+                          setIsRefreshing(true);
+                          try {
+                            await api.refreshPrinterStatus(printerId);
+                            await new Promise((r) => setTimeout(r, 500));
+                            await queryClient.refetchQueries({ queryKey: ['printer-status', printerId] });
+                          } finally {
+                            setIsRefreshing(false);
+                          }
+                        }}
+                        className="flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white"
+                        disabled={isRefreshing}
+                      >
+                        <RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
+                        <span>Re-read</span>
+                      </button>
+                    </div>
+                    {filamentComparison.map((item, idx) => (
+                      <div
+                        key={idx}
+                        className="grid items-center gap-2 text-xs"
+                        style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 16px' }}
+                      >
+                        <span title={`Required: ${item.type} - ${getColorName(item.color)}`}>
+                          <Circle className="w-3 h-3" fill={item.color} stroke={item.color} />
+                        </span>
+                        <span className="text-white truncate">
+                          {item.type} <span className="text-bambu-gray">({item.used_grams}g)</span>
+                        </span>
+                        <span className="text-bambu-gray">→</span>
+                        <select
+                          value={item.loaded?.globalTrayId ?? ''}
+                          onChange={(e) => {
+                            const slotId = item.slot_id || 0;
+                            if (slotId > 0) {
+                              const value = e.target.value;
+                              if (value === '') {
+                                setManualMappings((prev) => {
+                                  const next = { ...prev };
+                                  delete next[slotId];
+                                  return next;
+                                });
+                              } else {
+                                setManualMappings((prev) => ({
+                                  ...prev,
+                                  [slotId]: parseInt(value, 10),
+                                }));
+                              }
+                            }
+                          }}
+                          className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
+                            item.status === 'match'
+                              ? 'border-bambu-green/50 text-bambu-green'
+                              : item.status === 'type_only'
+                              ? 'border-yellow-400/50 text-yellow-400'
+                              : 'border-orange-400/50 text-orange-400'
+                          } ${item.isManual ? 'ring-1 ring-blue-400/50' : ''}`}
+                          title={item.isManual ? 'Manually selected' : 'Auto-matched'}
+                        >
+                          <option value="" className="bg-bambu-dark text-bambu-gray">
+                            -- Select slot --
+                          </option>
+                          {loadedFilaments.map((f) => (
+                            <option
+                              key={f.globalTrayId}
+                              value={f.globalTrayId}
+                              className="bg-bambu-dark text-white"
+                            >
+                              {f.label}: {f.type} ({f.colorName})
+                            </option>
+                          ))}
+                        </select>
+                        {item.status === 'match' ? (
+                          <Check className="w-3 h-3 text-bambu-green" />
+                        ) : item.status === 'type_only' ? (
+                          <span title="Same type, different color">
+                            <AlertTriangle className="w-3 h-3 text-yellow-400" />
+                          </span>
+                        ) : (
+                          <span title="Filament type not loaded">
+                            <AlertTriangle className="w-3 h-3 text-orange-400" />
+                          </span>
+                        )}
+                      </div>
+                    ))}
+                  </div>
+                )}
+              </div>
+            )}
+
             {/* Schedule type */}
             <div>
               <label className="block text-sm text-bambu-gray mb-2">When to print</label>

+ 187 - 0
frontend/src/components/BatchProjectModal.tsx

@@ -0,0 +1,187 @@
+import { useEffect } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { X, FolderKanban, Loader2, XCircle } from 'lucide-react';
+import { api } from '../api/client';
+import { Card, CardContent } from './Card';
+import { Button } from './Button';
+import { useToast } from '../contexts/ToastContext';
+
+interface BatchProjectModalProps {
+  selectedIds: number[];
+  onClose: () => void;
+}
+
+export function BatchProjectModal({ selectedIds, onClose }: BatchProjectModalProps) {
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+
+  const { data: projects, isLoading } = useQuery({
+    queryKey: ['projects'],
+    queryFn: () => api.getProjects(),
+  });
+
+  // Close on Escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  // Helper to invalidate all project-related queries
+  const invalidateProjectQueries = () => {
+    queryClient.invalidateQueries({ queryKey: ['archives'] });
+    queryClient.invalidateQueries({ queryKey: ['projects'] });
+    // Invalidate project detail pages (partial match catches all project IDs)
+    queryClient.invalidateQueries({ queryKey: ['project'] });
+    queryClient.invalidateQueries({ queryKey: ['project-archives'] });
+  };
+
+  // Assign to project mutation (uses bulk API)
+  const assignMutation = useMutation({
+    mutationFn: async (projectId: number) => {
+      await api.addArchivesToProject(projectId, selectedIds);
+      return projectId;
+    },
+    onSuccess: (projectId) => {
+      const project = projects?.find(p => p.id === projectId);
+      invalidateProjectQueries();
+      showToast(`Added ${selectedIds.length} archive${selectedIds.length !== 1 ? 's' : ''} to "${project?.name}"`);
+      onClose();
+    },
+    onError: () => {
+      showToast('Failed to assign project', 'error');
+    },
+  });
+
+  // Remove from project mutation (updates each archive individually)
+  const removeMutation = useMutation({
+    mutationFn: async () => {
+      for (const id of selectedIds) {
+        await api.updateArchive(id, { project_id: null });
+      }
+      return selectedIds.length;
+    },
+    onSuccess: (count) => {
+      invalidateProjectQueries();
+      showToast(`Removed ${count} archive${count !== 1 ? 's' : ''} from project`);
+      onClose();
+    },
+    onError: () => {
+      showToast('Failed to remove from project', 'error');
+    },
+  });
+
+  const isPending = assignMutation.isPending || removeMutation.isPending;
+
+  return (
+    <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
+      <Card className="w-full max-w-md max-h-[80vh] flex flex-col">
+        <CardContent className="p-0 flex flex-col min-h-0">
+          {/* Header */}
+          <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary shrink-0">
+            <div className="flex items-center gap-2">
+              <FolderKanban className="w-5 h-5 text-bambu-green" />
+              <h2 className="text-xl font-semibold text-white">
+                Assign to Project
+              </h2>
+            </div>
+            <button
+              onClick={onClose}
+              className="text-bambu-gray hover:text-white transition-colors"
+              disabled={isPending}
+            >
+              <X className="w-5 h-5" />
+            </button>
+          </div>
+
+          {/* Content */}
+          <div className="p-4 space-y-3 overflow-y-auto min-h-0">
+            <p className="text-sm text-bambu-gray">
+              Assign {selectedIds.length} selected archive{selectedIds.length !== 1 ? 's' : ''} to a project
+            </p>
+
+            {isLoading ? (
+              <div className="flex items-center justify-center py-8">
+                <Loader2 className="w-6 h-6 animate-spin text-bambu-gray" />
+              </div>
+            ) : (
+              <div className="space-y-2">
+                {/* Remove from project option */}
+                <button
+                  onClick={() => removeMutation.mutate()}
+                  disabled={isPending}
+                  className="w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary transition-colors text-left disabled:opacity-50"
+                >
+                  <div className="w-8 h-8 rounded-full bg-red-500/20 flex items-center justify-center shrink-0">
+                    <XCircle className="w-4 h-4 text-red-400" />
+                  </div>
+                  <div className="min-w-0 flex-1">
+                    <p className="text-white font-medium">Remove from project</p>
+                    <p className="text-sm text-bambu-gray truncate">Clear project assignment</p>
+                  </div>
+                  {removeMutation.isPending && (
+                    <Loader2 className="w-4 h-4 animate-spin text-bambu-gray shrink-0" />
+                  )}
+                </button>
+
+                {/* Divider */}
+                {projects && projects.length > 0 && (
+                  <div className="flex items-center gap-2 py-2">
+                    <div className="flex-1 h-px bg-bambu-dark-tertiary" />
+                    <span className="text-xs text-bambu-gray">or assign to</span>
+                    <div className="flex-1 h-px bg-bambu-dark-tertiary" />
+                  </div>
+                )}
+
+                {/* Project list */}
+                {projects?.map((project) => (
+                  <button
+                    key={project.id}
+                    onClick={() => assignMutation.mutate(project.id)}
+                    disabled={isPending}
+                    className="w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary transition-colors text-left disabled:opacity-50"
+                  >
+                    <div
+                      className="w-8 h-8 rounded-full flex items-center justify-center shrink-0"
+                      style={{ backgroundColor: project.color ? `${project.color}20` : 'rgb(var(--bambu-green) / 0.2)' }}
+                    >
+                      <FolderKanban
+                        className="w-4 h-4"
+                        style={{ color: project.color || 'rgb(var(--bambu-green))' }}
+                      />
+                    </div>
+                    <div className="min-w-0 flex-1">
+                      <p className="text-white font-medium truncate">{project.name}</p>
+                      <p className="text-sm text-bambu-gray truncate">
+                        {project.archive_count} archive{project.archive_count !== 1 ? 's' : ''}
+                        {project.status && ` • ${project.status}`}
+                      </p>
+                    </div>
+                    {assignMutation.isPending && assignMutation.variables === project.id && (
+                      <Loader2 className="w-4 h-4 animate-spin text-bambu-gray shrink-0" />
+                    )}
+                  </button>
+                ))}
+
+                {(!projects || projects.length === 0) && (
+                  <p className="text-center text-bambu-gray py-4">
+                    No projects yet. Create one from the Projects page.
+                  </p>
+                )}
+              </div>
+            )}
+          </div>
+
+          {/* Footer */}
+          <div className="flex gap-3 p-4 border-t border-bambu-dark-tertiary shrink-0">
+            <Button variant="secondary" onClick={onClose} className="flex-1" disabled={isPending}>
+              Cancel
+            </Button>
+          </div>
+        </CardContent>
+      </Card>
+    </div>
+  );
+}

+ 28 - 8
frontend/src/components/CalendarView.tsx

@@ -2,10 +2,12 @@ import { useState, useMemo } from 'react';
 import { ChevronLeft, ChevronRight } from 'lucide-react';
 import type { Archive } from '../api/client';
 import { api } from '../api/client';
+import { parseUTCDate } from '../utils/date';
 
 interface CalendarViewProps {
   archives: Archive[];
   onArchiveClick?: (archive: Archive) => void;
+  highlightedArchiveId?: number | null;
 }
 
 function getDaysInMonth(year: number, month: number): number {
@@ -23,17 +25,18 @@ const MONTH_NAMES = [
 
 const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
 
-export function CalendarView({ archives, onArchiveClick }: CalendarViewProps) {
+export function CalendarView({ archives, onArchiveClick, highlightedArchiveId }: CalendarViewProps) {
   const today = new Date();
   const [currentMonth, setCurrentMonth] = useState(today.getMonth());
   const [currentYear, setCurrentYear] = useState(today.getFullYear());
   const [selectedDate, setSelectedDate] = useState<string | null>(null);
+  const [selectedArchiveId, setSelectedArchiveId] = useState<number | null>(null);
 
-  // Group archives by date
+  // Group archives by date (using local timezone from UTC timestamps)
   const archivesByDate = useMemo(() => {
     const map = new Map<string, Archive[]>();
     archives.forEach(archive => {
-      const date = new Date(archive.completed_at || archive.created_at);
+      const date = parseUTCDate(archive.completed_at || archive.created_at) || new Date();
       const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
       const existing = map.get(key) || [];
       existing.push(archive);
@@ -79,6 +82,14 @@ export function CalendarView({ archives, onArchiveClick }: CalendarViewProps) {
 
   const selectedArchives = selectedDate ? archivesByDate.get(selectedDate) || [] : [];
 
+  // Clear selected archive when date changes
+  const handleDateSelect = (dateKey: string | null) => {
+    if (dateKey !== selectedDate) {
+      setSelectedArchiveId(null);
+    }
+    setSelectedDate(dateKey);
+  };
+
   return (
     <div className="flex flex-col lg:flex-row gap-6">
       {/* Calendar */}
@@ -137,7 +148,7 @@ export function CalendarView({ archives, onArchiveClick }: CalendarViewProps) {
             return (
               <button
                 key={day}
-                onClick={() => setSelectedDate(isSelected ? null : dateKey)}
+                onClick={() => handleDateSelect(isSelected ? null : dateKey)}
                 className={`aspect-square rounded-lg p-1 flex flex-col items-center justify-center transition-colors relative ${
                   isSelected
                     ? 'bg-bambu-green text-white'
@@ -216,11 +227,19 @@ export function CalendarView({ archives, onArchiveClick }: CalendarViewProps) {
             </h3>
             {selectedArchives.length > 0 ? (
               <div className="space-y-2 max-h-96 overflow-y-auto">
-                {selectedArchives.map(archive => (
+                {selectedArchives.map(archive => {
+                  const isHighlighted = archive.id === selectedArchiveId || archive.id === highlightedArchiveId;
+                  return (
                   <button
                     key={archive.id}
-                    onClick={() => onArchiveClick?.(archive)}
-                    className="w-full flex items-center gap-3 p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-left"
+                    onClick={() => {
+                      setSelectedArchiveId(archive.id);
+                      onArchiveClick?.(archive);
+                    }}
+                    className={`w-full flex items-center gap-3 p-2 rounded-lg transition-colors text-left ${
+                      !isHighlighted ? 'hover:bg-bambu-dark-tertiary' : ''
+                    }`}
+                    style={isHighlighted ? { outline: '4px solid #facc15', outlineOffset: '2px' } : undefined}
                   >
                     {archive.thumbnail_path ? (
                       <img
@@ -255,7 +274,8 @@ export function CalendarView({ archives, onArchiveClick }: CalendarViewProps) {
                       </div>
                     </div>
                   </button>
-                ))}
+                  );
+                })}
               </div>
             ) : (
               <p className="text-sm text-bambu-gray">No prints on this day</p>

+ 4 - 3
frontend/src/components/Card.tsx

@@ -1,18 +1,19 @@
-import type { ReactNode, MouseEvent } from 'react';
+import type { ReactNode, MouseEvent, HTMLAttributes } from 'react';
 
-interface CardProps {
+interface CardProps extends HTMLAttributes<HTMLDivElement> {
   children: ReactNode;
   className?: string;
   onClick?: (e: MouseEvent) => void;
   onContextMenu?: (e: MouseEvent) => void;
 }
 
-export function Card({ children, className = '', onClick, onContextMenu }: CardProps) {
+export function Card({ children, className = '', onClick, onContextMenu, ...rest }: CardProps) {
   return (
     <div
       className={`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary card-shadow ${className}`}
       onClick={onClick}
       onContextMenu={onContextMenu}
+      {...rest}
     >
       {children}
     </div>

+ 9 - 7
frontend/src/components/Dashboard.tsx

@@ -22,7 +22,8 @@ import { Button } from './Button';
 export interface DashboardWidget {
   id: string;
   title: string;
-  component: ReactNode;
+  /** Render function that receives the current size for responsive content */
+  component: ReactNode | ((size: 1 | 2 | 4) => ReactNode);
   defaultVisible?: boolean;
   defaultSize?: 1 | 2 | 4; // 1 = quarter, 2 = half, 4 = full width (default)
 }
@@ -50,7 +51,7 @@ interface LayoutState {
 function SortableWidget({
   id,
   title,
-  children,
+  component,
   isHidden,
   size,
   onToggleVisibility,
@@ -58,7 +59,7 @@ function SortableWidget({
 }: {
   id: string;
   title: string;
-  children: ReactNode;
+  component: ReactNode | ((size: 1 | 2 | 4) => ReactNode);
   isHidden: boolean;
   size: 1 | 2 | 4;
   onToggleVisibility: () => void;
@@ -127,7 +128,9 @@ function SortableWidget({
         </div>
       </div>
       {/* Widget Content */}
-      <div className="p-4">{children}</div>
+      <div className="p-4">
+        {typeof component === 'function' ? component(size) : component}
+      </div>
     </div>
   );
 }
@@ -329,13 +332,12 @@ export function Dashboard({ widgets, storageKey, columns = 4, hideControls = fal
                 key={widget.id}
                 id={widget.id}
                 title={widget.title}
+                component={widget.component}
                 isHidden={layout.hidden.includes(widget.id)}
                 size={layout.sizes[widget.id] || 2}
                 onToggleVisibility={() => toggleVisibility(widget.id)}
                 onToggleSize={() => toggleSize(widget.id)}
-              >
-                {widget.component}
-              </SortableWidget>
+              />
             ))}
           </div>
         </SortableContext>

+ 360 - 3
frontend/src/components/EditQueueItemModal.tsx

@@ -1,11 +1,12 @@
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useMemo } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Calendar, Clock, X, AlertCircle, Power, Pencil, Hand } from 'lucide-react';
+import { Calendar, Clock, X, AlertCircle, Power, Pencil, Hand, Check, AlertTriangle, Circle, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
 import { api } from '../api/client';
 import type { PrintQueueItem, PrintQueueItemUpdate } from '../api/client';
 import { Card, CardContent } from './Card';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
+import { getColorName } from '../utils/colors';
 
 interface EditQueueItemModalProps {
   item: PrintQueueItem;
@@ -37,12 +38,49 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
   });
   const [requirePreviousSuccess, setRequirePreviousSuccess] = useState(item.require_previous_success);
   const [autoOffAfter, setAutoOffAfter] = useState(item.auto_off_after);
+  const [showFilamentMapping, setShowFilamentMapping] = useState(false);
+  const [isRefreshing, setIsRefreshing] = useState(false);
+  // Manual slot overrides: slot_id (1-indexed) -> globalTrayId
+  // Initialize from existing ams_mapping if present
+  const [manualMappings, setManualMappings] = useState<Record<number, number>>(() => {
+    if (item.ams_mapping && Array.isArray(item.ams_mapping)) {
+      const mappings: Record<number, number> = {};
+      item.ams_mapping.forEach((globalTrayId, idx) => {
+        if (globalTrayId !== -1) {
+          mappings[idx + 1] = globalTrayId;
+        }
+      });
+      return mappings;
+    }
+    return {};
+  });
 
   const { data: printers } = useQuery({
     queryKey: ['printers'],
     queryFn: () => api.getPrinters(),
   });
 
+  // Fetch filament requirements from the archived 3MF
+  const { data: filamentReqs } = useQuery({
+    queryKey: ['archive-filaments', item.archive_id],
+    queryFn: () => api.getArchiveFilamentRequirements(item.archive_id),
+  });
+
+  // Fetch printer status when a printer is selected
+  const { data: printerStatus } = useQuery({
+    queryKey: ['printer-status', printerId],
+    queryFn: () => api.getPrinterStatus(printerId),
+    enabled: !!printerId,
+  });
+
+  // Clear manual mappings when printer changes (but not on initial load)
+  const [initialPrinterId] = useState(item.printer_id);
+  useEffect(() => {
+    if (printerId !== initialPrinterId) {
+      setManualMappings({});
+    }
+  }, [printerId, initialPrinterId]);
+
   // Close on Escape key
   useEffect(() => {
     const handleKeyDown = (e: KeyboardEvent) => {
@@ -52,6 +90,207 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
     return () => window.removeEventListener('keydown', handleKeyDown);
   }, [onClose]);
 
+  // Helper to normalize color format (API returns "RRGGBBAA", 3MF uses "#RRGGBB")
+  const normalizeColor = (color: string | null | undefined): string => {
+    if (!color) return '#808080';
+    const hex = color.replace('#', '').substring(0, 6);
+    return `#${hex}`;
+  };
+
+  // Helper to format slot label for display
+  const formatSlotLabel = (amsId: number, trayId: number, isHt: boolean, isExternal: boolean): string => {
+    if (isExternal) return 'External';
+    const letter = String.fromCharCode(65 + (amsId >= 128 ? amsId - 128 : amsId));
+    if (isHt) return `HT-${letter}`;
+    return `AMS-${letter} Slot ${trayId + 1}`;
+  };
+
+  // Calculate global tray ID for MQTT command
+  const getGlobalTrayId = (amsId: number, trayId: number, isExternal: boolean): number => {
+    if (isExternal) return 254;
+    return amsId * 4 + trayId;
+  };
+
+  // Build a list of all loaded filaments from printer's AMS/HT/External
+  const loadedFilaments = useMemo(() => {
+    const filaments: Array<{
+      type: string;
+      color: string;
+      colorName: string;
+      amsId: number;
+      trayId: number;
+      isHt: boolean;
+      isExternal: boolean;
+      label: string;
+      globalTrayId: number;
+    }> = [];
+
+    printerStatus?.ams?.forEach((amsUnit) => {
+      const isHt = amsUnit.tray.length === 1;
+      amsUnit.tray.forEach((tray) => {
+        if (tray.tray_type) {
+          const color = normalizeColor(tray.tray_color);
+          filaments.push({
+            type: tray.tray_type,
+            color,
+            colorName: getColorName(color),
+            amsId: amsUnit.id,
+            trayId: tray.id,
+            isHt,
+            isExternal: false,
+            label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
+            globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
+          });
+        }
+      });
+    });
+
+    if (printerStatus?.vt_tray?.tray_type) {
+      const color = normalizeColor(printerStatus.vt_tray.tray_color);
+      filaments.push({
+        type: printerStatus.vt_tray.tray_type,
+        color,
+        colorName: getColorName(color),
+        amsId: -1,
+        trayId: 0,
+        isHt: false,
+        isExternal: true,
+        label: 'External',
+        globalTrayId: 254,
+      });
+    }
+
+    return filaments;
+  }, [printerStatus]);
+
+  // Compare required filaments with loaded filaments
+  const filamentComparison = useMemo(() => {
+    if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return [];
+
+    const normalizeColorForCompare = (color: string | undefined): string => {
+      if (!color) return '';
+      return color.replace('#', '').toLowerCase().substring(0, 6);
+    };
+
+    const colorsAreSimilar = (color1: string | undefined, color2: string | undefined, threshold = 40): boolean => {
+      const hex1 = normalizeColorForCompare(color1);
+      const hex2 = normalizeColorForCompare(color2);
+      if (!hex1 || !hex2 || hex1.length < 6 || hex2.length < 6) return false;
+
+      const r1 = parseInt(hex1.substring(0, 2), 16);
+      const g1 = parseInt(hex1.substring(2, 4), 16);
+      const b1 = parseInt(hex1.substring(4, 6), 16);
+      const r2 = parseInt(hex2.substring(0, 2), 16);
+      const g2 = parseInt(hex2.substring(2, 4), 16);
+      const b2 = parseInt(hex2.substring(4, 6), 16);
+
+      return Math.abs(r1 - r2) <= threshold &&
+             Math.abs(g1 - g2) <= threshold &&
+             Math.abs(b1 - b2) <= threshold;
+    };
+
+    const usedTrayIds = new Set<number>(Object.values(manualMappings));
+
+    return filamentReqs.filaments.map((req) => {
+      const slotId = req.slot_id || 0;
+
+      // Check if there's a manual override for this slot
+      if (slotId > 0 && manualMappings[slotId] !== undefined) {
+        const manualTrayId = manualMappings[slotId];
+        const manualLoaded = loadedFilaments.find((f) => f.globalTrayId === manualTrayId);
+
+        if (manualLoaded) {
+          const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();
+          const colorMatch = normalizeColorForCompare(manualLoaded.color) === normalizeColorForCompare(req.color) ||
+                            colorsAreSimilar(manualLoaded.color, req.color);
+
+          let status: 'match' | 'type_only' | 'mismatch' | 'empty';
+          if (typeMatch && colorMatch) {
+            status = 'match';
+          } else if (typeMatch) {
+            status = 'type_only';
+          } else {
+            status = 'mismatch';
+          }
+
+          return {
+            ...req,
+            loaded: manualLoaded,
+            hasFilament: true,
+            typeMatch,
+            colorMatch,
+            status,
+            isManual: true,
+          };
+        }
+      }
+
+      // Auto-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(
+        (f) => !usedTrayIds.has(f.globalTrayId) &&
+               f.type?.toUpperCase() === req.type?.toUpperCase() &&
+               colorsAreSimilar(f.color, req.color)
+      );
+      const typeOnlyMatch = !exactMatch && !similarMatch && loadedFilaments.find(
+        (f) => !usedTrayIds.has(f.globalTrayId) &&
+               f.type?.toUpperCase() === req.type?.toUpperCase()
+      );
+      const loaded = exactMatch || similarMatch || typeOnlyMatch || undefined;
+
+      if (loaded) {
+        usedTrayIds.add(loaded.globalTrayId);
+      }
+
+      const hasFilament = !!loaded;
+      const typeMatch = hasFilament;
+      const colorMatch = !!exactMatch || !!similarMatch;
+
+      let status: 'match' | 'type_only' | 'mismatch' | 'empty';
+      if (exactMatch || similarMatch) {
+        status = 'match';
+      } else if (typeOnlyMatch) {
+        status = 'type_only';
+      } else {
+        status = 'mismatch';
+      }
+
+      return {
+        ...req,
+        loaded,
+        hasFilament,
+        typeMatch,
+        colorMatch,
+        status,
+        isManual: false,
+      };
+    });
+  }, [filamentReqs, loadedFilaments, manualMappings]);
+
+  // Build AMS mapping array
+  const amsMapping = useMemo(() => {
+    if (filamentComparison.length === 0) return undefined;
+
+    const maxSlotId = Math.max(...filamentComparison.map((f) => f.slot_id || 0));
+    if (maxSlotId <= 0) return undefined;
+
+    const mapping = new Array(maxSlotId).fill(-1);
+
+    filamentComparison.forEach((f) => {
+      if (f.slot_id && f.slot_id > 0) {
+        mapping[f.slot_id - 1] = f.loaded?.globalTrayId ?? -1;
+      }
+    });
+
+    return mapping;
+  }, [filamentComparison]);
+
+  const hasFilamentReqs = filamentReqs?.filaments && filamentReqs.filaments.length > 0;
+
   const updateMutation = useMutation({
     mutationFn: (data: PrintQueueItemUpdate) => api.updateQueueItem(item.id, data),
     onSuccess: () => {
@@ -72,6 +311,7 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
       require_previous_success: requirePreviousSuccess,
       auto_off_after: autoOffAfter,
       manual_start: scheduleType === 'manual',
+      ams_mapping: amsMapping,
     };
 
     if (scheduleType === 'scheduled' && scheduledTime) {
@@ -95,7 +335,7 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
       className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
       onClick={onClose}
     >
-      <Card className="w-full max-w-md" onClick={(e) => e.stopPropagation()}>
+      <Card className="w-full max-w-lg max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
         <CardContent className="p-0">
           {/* Header */}
           <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
@@ -143,6 +383,123 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
               )}
             </div>
 
+            {/* Filament Mapping Section */}
+            {printerId && hasFilamentReqs && (
+              <div>
+                <button
+                  type="button"
+                  onClick={() => setShowFilamentMapping(!showFilamentMapping)}
+                  className="flex items-center gap-2 text-sm text-bambu-gray hover:text-white transition-colors w-full"
+                >
+                  <Circle className="w-4 h-4" fill={filamentComparison.some(f => f.status === 'mismatch') ? '#f97316' : filamentComparison.some(f => f.status === 'type_only') ? '#facc15' : '#00ae42'} stroke="none" />
+                  <span>Filament Mapping</span>
+                  {filamentComparison.some(f => f.status === 'mismatch') ? (
+                    <span className="text-xs text-orange-400">(Type not found)</span>
+                  ) : filamentComparison.some(f => f.status === 'type_only') ? (
+                    <span className="text-xs text-yellow-400">(Color mismatch)</span>
+                  ) : (
+                    <span className="text-xs text-bambu-green">(Ready)</span>
+                  )}
+                  {showFilamentMapping ? <ChevronUp className="w-4 h-4 ml-auto" /> : <ChevronDown className="w-4 h-4 ml-auto" />}
+                </button>
+
+                {showFilamentMapping && (
+                  <div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
+                    <div className="flex items-center justify-between mb-2">
+                      <span className="text-xs text-bambu-gray">Click to change slot assignment</span>
+                      <button
+                        type="button"
+                        onClick={async () => {
+                          if (!printerId) return;
+                          setIsRefreshing(true);
+                          try {
+                            await api.refreshPrinterStatus(printerId);
+                            await new Promise((r) => setTimeout(r, 500));
+                            await queryClient.refetchQueries({ queryKey: ['printer-status', printerId] });
+                          } finally {
+                            setIsRefreshing(false);
+                          }
+                        }}
+                        className="flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white"
+                        disabled={isRefreshing}
+                      >
+                        <RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
+                        <span>Re-read</span>
+                      </button>
+                    </div>
+                    {filamentComparison.map((item, idx) => (
+                      <div
+                        key={idx}
+                        className="grid items-center gap-2 text-xs"
+                        style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 16px' }}
+                      >
+                        <span title={`Required: ${item.type} - ${getColorName(item.color)}`}>
+                          <Circle className="w-3 h-3" fill={item.color} stroke={item.color} />
+                        </span>
+                        <span className="text-white truncate">
+                          {item.type} <span className="text-bambu-gray">({item.used_grams}g)</span>
+                        </span>
+                        <span className="text-bambu-gray">→</span>
+                        <select
+                          value={item.loaded?.globalTrayId ?? ''}
+                          onChange={(e) => {
+                            const slotId = item.slot_id || 0;
+                            if (slotId > 0) {
+                              const value = e.target.value;
+                              if (value === '') {
+                                setManualMappings((prev) => {
+                                  const next = { ...prev };
+                                  delete next[slotId];
+                                  return next;
+                                });
+                              } else {
+                                setManualMappings((prev) => ({
+                                  ...prev,
+                                  [slotId]: parseInt(value, 10),
+                                }));
+                              }
+                            }
+                          }}
+                          className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
+                            item.status === 'match'
+                              ? 'border-bambu-green/50 text-bambu-green'
+                              : item.status === 'type_only'
+                              ? 'border-yellow-400/50 text-yellow-400'
+                              : 'border-orange-400/50 text-orange-400'
+                          } ${item.isManual ? 'ring-1 ring-blue-400/50' : ''}`}
+                          title={item.isManual ? 'Manually selected' : 'Auto-matched'}
+                        >
+                          <option value="" className="bg-bambu-dark text-bambu-gray">
+                            -- Select slot --
+                          </option>
+                          {loadedFilaments.map((f) => (
+                            <option
+                              key={f.globalTrayId}
+                              value={f.globalTrayId}
+                              className="bg-bambu-dark text-white"
+                            >
+                              {f.label}: {f.type} ({f.colorName})
+                            </option>
+                          ))}
+                        </select>
+                        {item.status === 'match' ? (
+                          <Check className="w-3 h-3 text-bambu-green" />
+                        ) : item.status === 'type_only' ? (
+                          <span title="Same type, different color">
+                            <AlertTriangle className="w-3 h-3 text-yellow-400" />
+                          </span>
+                        ) : (
+                          <span title="Filament type not loaded">
+                            <AlertTriangle className="w-3 h-3 text-orange-400" />
+                          </span>
+                        )}
+                      </div>
+                    ))}
+                  </div>
+                )}
+              </div>
+            )}
+
             {/* Schedule type */}
             <div>
               <label className="block text-sm text-bambu-gray mb-2">When to print</label>

+ 8 - 6
frontend/src/components/FilamentTrends.tsx

@@ -15,6 +15,7 @@ import {
   Legend,
 } from 'recharts';
 import type { Archive } from '../api/client';
+import { parseUTCDate } from '../utils/date';
 
 interface FilamentTrendsProps {
   archives: Archive[];
@@ -47,7 +48,7 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
   // Filter archives by time range
   const filteredArchives = useMemo(() => {
     const startDate = getDateRange(timeRange);
-    return archives.filter(a => new Date(a.completed_at || a.created_at) >= startDate);
+    return archives.filter(a => (parseUTCDate(a.completed_at || a.created_at) || new Date(0)) >= startDate);
   }, [archives, timeRange]);
 
   // Calculate daily usage data
@@ -55,8 +56,9 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
     const dataMap = new Map<string, { date: string; filament: number; cost: number; prints: number }>();
 
     filteredArchives.forEach(archive => {
-      const date = new Date(archive.completed_at || archive.created_at);
-      const key = date.toISOString().split('T')[0];
+      const date = parseUTCDate(archive.completed_at || archive.created_at) || new Date();
+      // Use local date string for grouping
+      const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
 
       const existing = dataMap.get(key) || { date: key, filament: 0, cost: 0, prints: 0 };
       existing.filament += archive.filament_used_grams || 0;
@@ -80,11 +82,11 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
     const dataMap = new Map<string, { week: string; filament: number; cost: number; prints: number }>();
 
     filteredArchives.forEach(archive => {
-      const date = new Date(archive.completed_at || archive.created_at);
+      const date = parseUTCDate(archive.completed_at || archive.created_at) || new Date();
       // Get week start (Sunday)
       const weekStart = new Date(date);
       weekStart.setDate(date.getDate() - date.getDay());
-      const key = weekStart.toISOString().split('T')[0];
+      const key = `${weekStart.getFullYear()}-${String(weekStart.getMonth() + 1).padStart(2, '0')}-${String(weekStart.getDate()).padStart(2, '0')}`;
 
       const existing = dataMap.get(key) || { week: key, filament: 0, cost: 0, prints: 0 };
       existing.filament += archive.filament_used_grams || 0;
@@ -132,7 +134,7 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
       const monthStr = monthDate.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
 
       const monthArchives = archives.filter(a => {
-        const d = new Date(a.completed_at || a.created_at);
+        const d = parseUTCDate(a.completed_at || a.created_at) || new Date(0);
         return d >= monthDate && d <= monthEnd;
       });
 

+ 48 - 2
frontend/src/components/Layout.tsx

@@ -1,12 +1,12 @@
 import { useState, useEffect, useCallback, useRef } from 'react';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
-import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, X, Menu, Info, Plug, type LucideIcon } from 'lucide-react';
+import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, X, Menu, Info, Plug, Bug, type LucideIcon } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
 import { SwitchbarPopover } from './SwitchbarPopover';
 import { useQuery } from '@tanstack/react-query';
-import { api } from '../api/client';
+import { api, supportApi } from '../api/client';
 import { getIconByName } from './IconPicker';
 import { useIsMobile } from '../hooks/useIsMobile';
 
@@ -118,6 +118,30 @@ export function Layout() {
 
   const hasSwitchbarPlugs = smartPlugs?.some(p => p.show_in_switchbar) ?? false;
 
+  // Check debug logging state
+  const { data: debugLoggingState } = useQuery({
+    queryKey: ['debugLogging'],
+    queryFn: supportApi.getDebugLoggingState,
+    staleTime: 60 * 1000, // 1 minute
+    refetchInterval: 60 * 1000, // Refresh every minute
+  });
+
+  // Calculate debug duration client-side for real-time updates
+  const [debugDuration, setDebugDuration] = useState<number | null>(null);
+  useEffect(() => {
+    if (!debugLoggingState?.enabled || !debugLoggingState.enabled_at) {
+      setDebugDuration(null);
+      return;
+    }
+    const enabledAt = new Date(debugLoggingState.enabled_at).getTime();
+    const updateDuration = () => {
+      setDebugDuration(Math.floor((Date.now() - enabledAt) / 1000));
+    };
+    updateDuration();
+    const interval = setInterval(updateDuration, 1000);
+    return () => clearInterval(interval);
+  }, [debugLoggingState?.enabled, debugLoggingState?.enabled_at]);
+
   // Build the unified sidebar items list
   const navItemsMap = new Map(defaultNavItems.map(item => [item.id, item]));
   const extLinksMap = new Map((externalLinks || []).map(link => [`ext-${link.id}`, link]));
@@ -592,6 +616,28 @@ export function Layout() {
       <main className={`flex-1 bg-bambu-dark overflow-auto transition-all duration-300 ${
         isMobile ? 'mt-14' : sidebarExpanded ? 'ml-64' : 'ml-16'
       }`}>
+        {/* Debug logging indicator */}
+        {debugLoggingState?.enabled && (
+          <div className="bg-amber-500/20 border-b border-amber-500/30 px-4 py-2 flex items-center justify-between">
+            <div className="flex items-center gap-2 text-sm">
+              <Bug className="w-4 h-4 text-amber-500 animate-pulse" />
+              <span className="text-amber-200">
+                {t('support.debugLoggingActive', { defaultValue: 'Debug logging is active' })}
+                {debugDuration !== null && (
+                  <span className="text-amber-300/70 ml-2">
+                    ({Math.floor(debugDuration / 60)}m {debugDuration % 60}s)
+                  </span>
+                )}
+              </span>
+              <button
+                onClick={() => navigate('/system')}
+                className="text-amber-400 hover:text-amber-300 font-medium underline ml-2"
+              >
+                {t('support.manageLogs', { defaultValue: 'Manage' })}
+              </button>
+            </div>
+          </div>
+        )}
         {/* Persistent update banner */}
         {showUpdateBanner && (
           <div className="bg-bambu-green/20 border-b border-bambu-green/30 px-4 py-2 flex items-center justify-between">

+ 15 - 3
frontend/src/components/NotificationLogViewer.tsx

@@ -2,6 +2,7 @@ import { useState } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { History, CheckCircle, XCircle, Loader2, Trash2, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
 import { api } from '../api/client';
+import { parseUTCDate, formatTimeOnly, formatDateTime, type TimeFormat } from '../utils/date';
 import type { NotificationLogEntry } from '../api/client';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
@@ -43,6 +44,13 @@ export function NotificationLogViewer({ onClose }: NotificationLogViewerProps) {
   const [expandedId, setExpandedId] = useState<number | null>(null);
   const [showFailedOnly, setShowFailedOnly] = useState(false);
 
+  const { data: settings } = useQuery({
+    queryKey: ['settings'],
+    queryFn: api.getSettings,
+  });
+
+  const timeFormat: TimeFormat = settings?.time_format || 'system';
+
   const { data: logs, isLoading, refetch, isRefetching } = useQuery({
     queryKey: ['notification-logs', days, showFailedOnly],
     queryFn: () => api.getNotificationLogs({
@@ -70,7 +78,8 @@ export function NotificationLogViewer({ onClose }: NotificationLogViewerProps) {
   });
 
   const formatDate = (dateStr: string) => {
-    const date = new Date(dateStr);
+    const date = parseUTCDate(dateStr);
+    if (!date) return '';
     const now = new Date();
     const diff = now.getTime() - date.getTime();
 
@@ -78,7 +87,7 @@ export function NotificationLogViewer({ onClose }: NotificationLogViewerProps) {
     if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
     if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
 
-    return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+    return date.toLocaleDateString() + ' ' + formatTimeOnly(date, timeFormat);
   };
 
   return (
@@ -189,6 +198,7 @@ export function NotificationLogViewer({ onClose }: NotificationLogViewerProps) {
                   isExpanded={expandedId === log.id}
                   onToggle={() => setExpandedId(expandedId === log.id ? null : log.id)}
                   formatDate={formatDate}
+                  formatFullDate={(dateStr) => formatDateTime(dateStr, timeFormat)}
                 />
               ))}
             </div>
@@ -211,11 +221,13 @@ function LogEntry({
   isExpanded,
   onToggle,
   formatDate,
+  formatFullDate,
 }: {
   log: NotificationLogEntry;
   isExpanded: boolean;
   onToggle: () => void;
   formatDate: (date: string) => string;
+  formatFullDate: (date: string) => string;
 }) {
   return (
     <div
@@ -278,7 +290,7 @@ function LogEntry({
           )}
           <div className="flex gap-4 text-xs text-bambu-gray pt-1">
             <span>Provider: {log.provider_type}</span>
-            <span>Time: {new Date(log.created_at).toLocaleString()}</span>
+            <span>Time: {formatFullDate(log.created_at)}</span>
           </div>
         </div>
       )}

+ 3 - 2
frontend/src/components/NotificationProviderCard.tsx

@@ -2,6 +2,7 @@ import { useState } from 'react';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
 import { Bell, Trash2, Settings2, Edit2, Send, Loader2, CheckCircle, XCircle, Moon, Clock, ChevronDown, ChevronUp, Calendar } from 'lucide-react';
 import { api } from '../api/client';
+import { formatDateOnly, parseUTCDate } from '../utils/date';
 import type { NotificationProvider, NotificationProviderUpdate } from '../api/client';
 import { Card, CardContent } from './Card';
 import { Button } from './Button';
@@ -90,11 +91,11 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
             {/* Quick enable/disable toggle + Status indicator */}
             <div className="flex items-center gap-3">
               {provider.last_success && (
-                <span className="text-xs text-bambu-green hidden sm:inline">Last: {new Date(provider.last_success).toLocaleDateString()}</span>
+                <span className="text-xs text-bambu-green hidden sm:inline">Last: {formatDateOnly(provider.last_success)}</span>
               )}
               {/* Only show error if it's more recent than last success */}
               {provider.last_error && provider.last_error_at && (
-                !provider.last_success || new Date(provider.last_error_at) > new Date(provider.last_success)
+                !provider.last_success || (parseUTCDate(provider.last_error_at)?.getTime() || 0) > (parseUTCDate(provider.last_success)?.getTime() || 0)
               ) && (
                 <span className="text-xs text-red-400" title={provider.last_error}>Error</span>
               )}

+ 106 - 60
frontend/src/components/PrintCalendar.tsx

@@ -1,4 +1,4 @@
-import { useMemo } from 'react';
+import { useMemo, useRef, useState, useEffect } from 'react';
 
 interface PrintCalendarProps {
   printDates: string[]; // Array of ISO date strings
@@ -6,6 +6,23 @@ interface PrintCalendarProps {
 }
 
 export function PrintCalendar({ printDates, months = 3 }: PrintCalendarProps) {
+  const containerRef = useRef<HTMLDivElement>(null);
+  const [containerWidth, setContainerWidth] = useState(0);
+
+  // Measure container width
+  useEffect(() => {
+    const container = containerRef.current;
+    if (!container) return;
+
+    const observer = new ResizeObserver((entries) => {
+      const width = entries[0]?.contentRect.width || 0;
+      setContainerWidth(width);
+    });
+
+    observer.observe(container);
+    return () => observer.disconnect();
+  }, []);
+
   const { weeks, monthLabels, printCounts } = useMemo(() => {
     // Count prints per day
     const counts: Record<string, number> = {};
@@ -68,72 +85,101 @@ export function PrintCalendar({ printDates, months = 3 }: PrintCalendarProps) {
 
   const dayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
 
+  // Calculate cell size based on container width
+  const numWeeks = weeks.length;
+  const dayLabelWidth = 32; // Space for day labels (Mon, Wed, Fri)
+  const gap = 2; // Gap between cells
+  const availableWidth = containerWidth - dayLabelWidth - 16; // 16px padding
+  const calculatedCellSize = numWeeks > 0 ? Math.floor((availableWidth - (numWeeks - 1) * gap) / numWeeks) : 12;
+
+  // Clamp cell size between 8 and 20 pixels
+  const cellSize = Math.max(8, Math.min(20, calculatedCellSize));
+  const fontSize = cellSize <= 10 ? 10 : 12;
+
   return (
-    <div className="overflow-x-auto">
-      {/* Month labels */}
-      <div className="flex mb-1 ml-8">
-        {monthLabels.map(({ month, weekIndex }, i) => (
-          <div
-            key={i}
-            className="text-xs text-bambu-gray"
-            style={{ marginLeft: i === 0 ? 0 : `${(weekIndex - (monthLabels[i - 1]?.weekIndex || 0)) * 14 - 24}px` }}
-          >
-            {month}
+    <div ref={containerRef} className="w-full flex justify-center">
+      {containerWidth > 0 && (
+        <div>
+          {/* Month labels */}
+          <div className="flex mb-1" style={{ marginLeft: dayLabelWidth + 4 }}>
+            {monthLabels.map(({ month, weekIndex }, i) => (
+              <div
+                key={i}
+                className="text-bambu-gray"
+                style={{
+                  fontSize,
+                  marginLeft: i === 0 ? 0 : `${(weekIndex - (monthLabels[i - 1]?.weekIndex || 0)) * (cellSize + gap) - 24}px`,
+                }}
+              >
+                {month}
+              </div>
+            ))}
           </div>
-        ))}
-      </div>
-
-      <div className="flex gap-0.5">
-        {/* Day labels */}
-        <div className="flex flex-col gap-0.5 mr-1">
-          {dayLabels.map((day, i) => (
-            <div
-              key={day}
-              className="h-3 text-xs text-bambu-gray flex items-center"
-              style={{ visibility: i % 2 === 1 ? 'visible' : 'hidden' }}
-            >
-              {day}
-            </div>
-          ))}
-        </div>
 
-        {/* Calendar grid */}
-        {weeks.map((week, weekIndex) => (
-          <div key={weekIndex} className="flex flex-col gap-0.5">
-            {[0, 1, 2, 3, 4, 5, 6].map((dayOfWeek) => {
-              const day = week.find((d) => d.getDay() === dayOfWeek);
-              if (!day) {
-                return <div key={dayOfWeek} className="w-3 h-3" />;
-              }
+          <div className="flex" style={{ gap }}>
+            {/* Day labels */}
+            <div className="flex flex-col" style={{ gap, marginRight: 4, width: dayLabelWidth }}>
+              {dayLabels.map((day, i) => (
+                <div
+                  key={day}
+                  className="text-bambu-gray flex items-center"
+                  style={{
+                    width: dayLabelWidth,
+                    height: cellSize,
+                    fontSize,
+                    visibility: i % 2 === 1 ? 'visible' : 'hidden',
+                  }}
+                >
+                  {day}
+                </div>
+              ))}
+            </div>
 
-              const dateStr = day.toISOString().split('T')[0];
-              const count = printCounts[dateStr] || 0;
-              const isToday = dateStr === new Date().toISOString().split('T')[0];
+            {/* Calendar grid */}
+            {weeks.map((week, weekIdx) => (
+              <div key={weekIdx} className="flex flex-col" style={{ gap }}>
+                {[0, 1, 2, 3, 4, 5, 6].map((dayOfWeek) => {
+                  const day = week.find((d) => d.getDay() === dayOfWeek);
+                  if (!day) {
+                    return (
+                      <div
+                        key={dayOfWeek}
+                        style={{ width: cellSize, height: cellSize }}
+                      />
+                    );
+                  }
+
+                  const dateStr = day.toISOString().split('T')[0];
+                  const count = printCounts[dateStr] || 0;
+                  const isToday = dateStr === new Date().toISOString().split('T')[0];
+
+                  return (
+                    <div
+                      key={dayOfWeek}
+                      className={`rounded-sm ${getColor(count)} ${isToday ? 'ring-1 ring-white' : ''}`}
+                      style={{ width: cellSize, height: cellSize }}
+                      title={`${day.toLocaleDateString()}: ${count} print${count !== 1 ? 's' : ''}`}
+                    />
+                  );
+                })}
+              </div>
+            ))}
+          </div>
 
-              return (
-                <div
-                  key={dayOfWeek}
-                  className={`w-3 h-3 rounded-sm ${getColor(count)} ${isToday ? 'ring-1 ring-white' : ''}`}
-                  title={`${day.toLocaleDateString()}: ${count} print${count !== 1 ? 's' : ''}`}
-                />
-              );
-            })}
+          {/* Legend */}
+          <div className="flex items-center gap-2 mt-3 text-bambu-gray" style={{ fontSize }}>
+            <span>Less</span>
+            <div className="flex" style={{ gap }}>
+              <div className="rounded-sm bg-bambu-dark" style={{ width: cellSize, height: cellSize }} />
+              <div className="rounded-sm bg-bambu-green/30" style={{ width: cellSize, height: cellSize }} />
+              <div className="rounded-sm bg-bambu-green/50" style={{ width: cellSize, height: cellSize }} />
+              <div className="rounded-sm bg-bambu-green/75" style={{ width: cellSize, height: cellSize }} />
+              <div className="rounded-sm bg-bambu-green" style={{ width: cellSize, height: cellSize }} />
+            </div>
+            <span>More</span>
           </div>
-        ))}
-      </div>
-
-      {/* Legend */}
-      <div className="flex items-center gap-2 mt-3 text-xs text-bambu-gray">
-        <span>Less</span>
-        <div className="flex gap-0.5">
-          <div className="w-3 h-3 rounded-sm bg-bambu-dark" />
-          <div className="w-3 h-3 rounded-sm bg-bambu-green/30" />
-          <div className="w-3 h-3 rounded-sm bg-bambu-green/50" />
-          <div className="w-3 h-3 rounded-sm bg-bambu-green/75" />
-          <div className="w-3 h-3 rounded-sm bg-bambu-green" />
         </div>
-        <span>More</span>
-      </div>
+      )}
     </div>
   );
 }

+ 3 - 1
frontend/src/components/PrinterQueueWidget.tsx

@@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query';
 import { Clock, Calendar, ChevronRight } from 'lucide-react';
 import { Link } from 'react-router-dom';
 import { api } from '../api/client';
+import { parseUTCDate } from '../utils/date';
 
 interface PrinterQueueWidgetProps {
   printerId: number;
@@ -9,7 +10,8 @@ interface PrinterQueueWidgetProps {
 
 function formatRelativeTime(dateString: string | null): string {
   if (!dateString) return 'ASAP';
-  const date = new Date(dateString);
+  const date = parseUTCDate(dateString);
+  if (!date) return 'ASAP';
   const now = new Date();
   const diff = date.getTime() - now.getTime();
 

+ 217 - 36
frontend/src/components/ReprintModal.tsx

@@ -1,9 +1,10 @@
 import { useState, useEffect, useMemo } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { X, Printer, Loader2, AlertTriangle, Check, Circle, RefreshCw } from 'lucide-react';
+import { X, Printer, Loader2, AlertTriangle, Check, Circle, RefreshCw, ChevronDown, ChevronUp, Settings } from 'lucide-react';
 import { api } from '../api/client';
 import { Card, CardContent } from './Card';
 import { Button } from './Button';
+import { getColorName } from '../utils/colors';
 
 interface ReprintModalProps {
   archiveId: number;
@@ -12,10 +13,36 @@ interface ReprintModalProps {
   onSuccess: () => void;
 }
 
+// Print options with defaults
+interface PrintOptions {
+  timelapse: boolean;
+  bed_levelling: boolean;
+  flow_cali: boolean;
+  vibration_cali: boolean;
+  layer_inspect: boolean;
+}
+
+const DEFAULT_PRINT_OPTIONS: PrintOptions = {
+  bed_levelling: true,
+  flow_cali: false,
+  vibration_cali: true,
+  layer_inspect: false,
+  timelapse: false,
+};
+
 export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: ReprintModalProps) {
   const queryClient = useQueryClient();
   const [selectedPrinter, setSelectedPrinter] = useState<number | null>(null);
   const [isRefreshing, setIsRefreshing] = useState(false);
+  const [showOptions, setShowOptions] = useState(false);
+  const [printOptions, setPrintOptions] = useState<PrintOptions>(DEFAULT_PRINT_OPTIONS);
+  // Manual slot overrides: slot_id (1-indexed) -> globalTrayId
+  const [manualMappings, setManualMappings] = useState<Record<number, number>>({});
+
+  // Clear manual mappings when printer changes
+  useEffect(() => {
+    setManualMappings({});
+  }, [selectedPrinter]);
 
   // Close on Escape key
   useEffect(() => {
@@ -47,7 +74,10 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
   const reprintMutation = useMutation({
     mutationFn: () => {
       if (!selectedPrinter) throw new Error('No printer selected');
-      return api.reprintArchive(archiveId, selectedPrinter);
+      return api.reprintArchive(archiveId, selectedPrinter, {
+        ams_mapping: amsMapping,
+        ...printOptions,
+      });
     },
     onSuccess: () => {
       onSuccess();
@@ -73,16 +103,25 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
     return `AMS-${letter} Slot ${trayId + 1}`;
   };
 
+  // Calculate global tray ID for MQTT command
+  // Regular AMS: (ams_id * 4) + slot_id, External: 254
+  const getGlobalTrayId = (amsId: number, trayId: number, isExternal: boolean): number => {
+    if (isExternal) return 254;
+    return amsId * 4 + trayId;
+  };
+
   // Build a list of all loaded filaments from printer's AMS/HT/External with location info
   const loadedFilaments = useMemo(() => {
     const filaments: Array<{
       type: string;
       color: string;
+      colorName: string;
       amsId: number;
       trayId: number;
       isHt: boolean;
       isExternal: boolean;
       label: string;
+      globalTrayId: number;
     }> = [];
 
     // Add filaments from all AMS units (regular and HT)
@@ -90,14 +129,17 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
       const isHt = amsUnit.tray.length === 1; // AMS-HT has single tray
       amsUnit.tray.forEach((tray) => {
         if (tray.tray_type) {
+          const color = normalizeColor(tray.tray_color);
           filaments.push({
             type: tray.tray_type,
-            color: normalizeColor(tray.tray_color),
+            color,
+            colorName: getColorName(color),
             amsId: amsUnit.id,
             trayId: tray.id,
             isHt,
             isExternal: false,
             label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
+            globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
           });
         }
       });
@@ -105,14 +147,17 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
 
     // Add external spool if loaded
     if (printerStatus?.vt_tray?.tray_type) {
+      const color = normalizeColor(printerStatus.vt_tray.tray_color);
       filaments.push({
         type: printerStatus.vt_tray.tray_type,
-        color: normalizeColor(printerStatus.vt_tray.tray_color),
+        color,
+        colorName: getColorName(color),
         amsId: -1,
         trayId: 0,
         isHt: false,
         isExternal: true,
         label: 'External',
+        globalTrayId: 254,
       });
     }
 
@@ -121,6 +166,7 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
 
   // Compare required filaments with loaded filaments
   // Match by filament TYPE (not slot), since the printer dynamically maps slots
+  // Respects manual overrides when set
   const filamentComparison = useMemo(() => {
     if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return [];
 
@@ -149,22 +195,68 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
              Math.abs(b1 - b2) <= threshold;
     };
 
+    // Track which trays have been assigned to avoid duplicates
+    // First, mark all manually assigned trays as used
+    const usedTrayIds = new Set<number>(Object.values(manualMappings));
+
     return filamentReqs.filaments.map((req) => {
-      // Find a loaded filament that matches by TYPE (printer will auto-map the slot)
+      const slotId = req.slot_id || 0;
+
+      // Check if there's a manual override for this slot
+      if (slotId > 0 && manualMappings[slotId] !== undefined) {
+        const manualTrayId = manualMappings[slotId];
+        const manualLoaded = loadedFilaments.find((f) => f.globalTrayId === manualTrayId);
+
+        if (manualLoaded) {
+          const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();
+          const colorMatch = normalizeColorForCompare(manualLoaded.color) === normalizeColorForCompare(req.color) ||
+                            colorsAreSimilar(manualLoaded.color, req.color);
+
+          let status: 'match' | 'type_only' | 'mismatch' | 'empty';
+          if (typeMatch && colorMatch) {
+            status = 'match';
+          } else if (typeMatch) {
+            status = 'type_only';
+          } else {
+            status = 'mismatch';
+          }
+
+          return {
+            ...req,
+            loaded: manualLoaded,
+            hasFilament: true,
+            typeMatch,
+            colorMatch,
+            status,
+            isManual: true,
+          };
+        }
+      }
+
+      // Auto-match: Find a loaded filament that matches by TYPE
       // Priority: exact color match > similar color match > type-only match
+      // IMPORTANT: Exclude trays that are already assigned (manually or auto)
       const exactMatch = loadedFilaments.find(
-        (f) => f.type?.toUpperCase() === req.type?.toUpperCase() &&
+        (f) => !usedTrayIds.has(f.globalTrayId) &&
+               f.type?.toUpperCase() === req.type?.toUpperCase() &&
                normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
       );
       const similarMatch = !exactMatch && loadedFilaments.find(
-        (f) => f.type?.toUpperCase() === req.type?.toUpperCase() &&
+        (f) => !usedTrayIds.has(f.globalTrayId) &&
+               f.type?.toUpperCase() === req.type?.toUpperCase() &&
                colorsAreSimilar(f.color, req.color)
       );
       const typeOnlyMatch = !exactMatch && !similarMatch && loadedFilaments.find(
-        (f) => f.type?.toUpperCase() === req.type?.toUpperCase()
+        (f) => !usedTrayIds.has(f.globalTrayId) &&
+               f.type?.toUpperCase() === req.type?.toUpperCase()
       );
       const loaded = exactMatch || similarMatch || typeOnlyMatch || undefined;
 
+      // Mark this tray as used so it won't be assigned to another slot
+      if (loaded) {
+        usedTrayIds.add(loaded.globalTrayId);
+      }
+
       const hasFilament = !!loaded;
       const typeMatch = hasFilament;
       const colorMatch = !!exactMatch || !!similarMatch;
@@ -186,15 +278,40 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
         typeMatch,
         colorMatch,
         status,
+        isManual: false,
       };
     });
-  }, [filamentReqs, loadedFilaments]);
+  }, [filamentReqs, loadedFilaments, manualMappings]);
+
+  // Build AMS mapping from auto-matched filaments
+  // Format: array matching 3MF filament slot structure
+  // Position = slot_id - 1 (0-indexed), value = global tray ID or -1 for unused
+  // e.g., slots 1 and 3 used with trays 5 and 2 → [5, -1, 2, -1]
+  const amsMapping = useMemo(() => {
+    if (filamentComparison.length === 0) return undefined;
+
+    // Find the max slot_id to determine array size
+    const maxSlotId = Math.max(...filamentComparison.map((f) => f.slot_id || 0));
+    if (maxSlotId <= 0) return undefined;
+
+    // Create array with -1 for all positions
+    const mapping = new Array(maxSlotId).fill(-1);
+
+    // Fill in tray IDs at correct positions (slot_id - 1)
+    filamentComparison.forEach((f) => {
+      if (f.slot_id && f.slot_id > 0) {
+        mapping[f.slot_id - 1] = f.loaded?.globalTrayId ?? -1;
+      }
+    });
+
+    return mapping;
+  }, [filamentComparison]);
 
   const hasTypeMismatch = filamentComparison.some((f) => f.status === 'mismatch');
 
   return (
     <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-8">
-      <Card className="w-full max-w-md">
+      <Card className="w-full max-w-lg">
         <CardContent>
           {/* Header */}
           <div className="flex items-center justify-between mb-4">
@@ -303,10 +420,10 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
                   <div
                     key={idx}
                     className="grid items-center gap-2"
-                    style={{ gridTemplateColumns: '16px 1fr auto 16px 1fr 16px' }}
+                    style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 16px' }}
                   >
                     {/* Required color */}
-                    <span title={`Required: ${item.color}`}>
+                    <span title={`Required: ${item.type} - ${getColorName(item.color)}`}>
                       <Circle
                         className="w-3 h-3 flex-shrink-0"
                         fill={item.color}
@@ -319,30 +436,50 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
                     </span>
                     {/* Arrow */}
                     <span className="text-bambu-gray">→</span>
-                    {/* Loaded color */}
-                    {item.loaded ? (
-                      <span title={`Loaded: ${item.loaded.color}`}>
-                        <Circle
-                          className="w-3 h-3 flex-shrink-0"
-                          fill={item.loaded.color}
-                          stroke={item.loaded.color}
-                        />
-                      </span>
-                    ) : (
-                      <span />
-                    )}
-                    {/* Loaded type + slot */}
-                    <span className={
-                      item.status === 'match' ? 'text-bambu-green' :
-                      item.status === 'type_only' ? 'text-yellow-400' :
-                      'text-orange-400'
-                    }>
-                      {item.loaded ? (
-                        <>{item.loaded.type} <span className="text-bambu-gray">({item.loaded.label})</span></>
-                      ) : (
-                        'Not loaded'
-                      )}
-                    </span>
+                    {/* Slot selector dropdown */}
+                    <select
+                      value={item.loaded?.globalTrayId ?? ''}
+                      onChange={(e) => {
+                        const slotId = item.slot_id || 0;
+                        if (slotId > 0) {
+                          const value = e.target.value;
+                          if (value === '') {
+                            // Clear manual override
+                            setManualMappings((prev) => {
+                              const next = { ...prev };
+                              delete next[slotId];
+                              return next;
+                            });
+                          } else {
+                            setManualMappings((prev) => ({
+                              ...prev,
+                              [slotId]: parseInt(value, 10),
+                            }));
+                          }
+                        }
+                      }}
+                      className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
+                        item.status === 'match'
+                          ? 'border-bambu-green/50 text-bambu-green'
+                          : item.status === 'type_only'
+                          ? 'border-yellow-400/50 text-yellow-400'
+                          : 'border-orange-400/50 text-orange-400'
+                      } ${item.isManual ? 'ring-1 ring-blue-400/50' : ''}`}
+                      title={item.isManual ? 'Manually selected' : 'Auto-matched'}
+                    >
+                      <option value="" className="bg-bambu-dark text-bambu-gray">
+                        -- Select slot --
+                      </option>
+                      {loadedFilaments.map((f) => (
+                        <option
+                          key={f.globalTrayId}
+                          value={f.globalTrayId}
+                          className="bg-bambu-dark text-white"
+                        >
+                          {f.label}: {f.type} ({f.colorName})
+                        </option>
+                      ))}
+                    </select>
                     {/* Status icon */}
                     {item.status === 'match' ? (
                       <Check className="w-3 h-3 text-bambu-green" />
@@ -366,6 +503,50 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
             </div>
           )}
 
+          {/* Print Options */}
+          {selectedPrinter && (
+            <div className="mb-4">
+              <button
+                onClick={() => setShowOptions(!showOptions)}
+                className="flex items-center gap-2 text-sm text-bambu-gray hover:text-white transition-colors w-full"
+              >
+                <Settings className="w-4 h-4" />
+                <span>Print Options</span>
+                {showOptions ? <ChevronUp className="w-4 h-4 ml-auto" /> : <ChevronDown className="w-4 h-4 ml-auto" />}
+              </button>
+              {showOptions && (
+                <div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
+                  {[
+                    { key: 'bed_levelling', label: 'Bed Levelling', desc: 'Auto-level bed before print' },
+                    { key: 'flow_cali', label: 'Flow Calibration', desc: 'Calibrate extrusion flow' },
+                    { key: 'vibration_cali', label: 'Vibration Calibration', desc: 'Reduce ringing artifacts' },
+                    { key: 'layer_inspect', label: 'First Layer Inspection', desc: 'AI inspection of first layer' },
+                    { key: 'timelapse', label: 'Timelapse', desc: 'Record timelapse video' },
+                  ].map(({ key, label, desc }) => (
+                    <label key={key} className="flex items-center justify-between cursor-pointer group">
+                      <div>
+                        <span className="text-sm text-white">{label}</span>
+                        <p className="text-xs text-bambu-gray">{desc}</p>
+                      </div>
+                      <div
+                        className={`relative w-10 h-5 rounded-full transition-colors ${
+                          printOptions[key as keyof PrintOptions] ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
+                        }`}
+                        onClick={() => setPrintOptions((prev) => ({ ...prev, [key]: !prev[key as keyof PrintOptions] }))}
+                      >
+                        <div
+                          className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
+                            printOptions[key as keyof PrintOptions] ? 'translate-x-5' : 'translate-x-0.5'
+                          }`}
+                        />
+                      </div>
+                    </label>
+                  ))}
+                </div>
+              )}
+            </div>
+          )}
+
           {/* Error message */}
           {reprintMutation.isError && (
             <div className="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">

+ 48 - 0
frontend/src/components/icons/ChamberLight.tsx

@@ -0,0 +1,48 @@
+interface ChamberLightProps {
+  on: boolean;
+  className?: string;
+}
+
+/**
+ * Chamber light icon with on/off states.
+ * Modern bulb design with radiating rays.
+ * - On: Filled yellow bulb with visible rays
+ * - Off: Outline only, muted color
+ */
+export function ChamberLight({ on, className = "w-5 h-5" }: ChamberLightProps) {
+  const bulbFill = on ? "#facc15" : "none"; // yellow-400 when on
+  const strokeColor = on ? "#78350f" : "currentColor"; // amber-900 when on
+  const rayOpacity = on ? 1 : 0;
+
+  return (
+    <svg
+      viewBox="0 0 32 32"
+      fill="none"
+      strokeWidth="2"
+      strokeLinecap="round"
+      strokeLinejoin="round"
+      className={className}
+    >
+      {/* Radiating rays */}
+      <g stroke={strokeColor} opacity={rayOpacity}>
+        <line x1="16" y1="2" x2="16" y2="6" />
+        <line x1="6.1" y1="6.1" x2="8.9" y2="8.9" />
+        <line x1="25.9" y1="6.1" x2="23.1" y2="8.9" />
+        <line x1="2" y1="16" x2="6" y2="16" />
+        <line x1="30" y1="16" x2="26" y2="16" />
+      </g>
+
+      {/* Bulb glass - smooth rounded shape */}
+      <path
+        d="M12 24v-2.3c0-.9-.4-1.7-1-2.3C9.2 17.6 8 15.4 8 13c0-4.4 3.6-8 8-8s8 3.6 8 8c0 2.4-1.2 4.6-3 6.4-.6.6-1 1.4-1 2.3V24"
+        fill={bulbFill}
+        stroke={strokeColor}
+      />
+
+      {/* Base rings */}
+      <path d="M12 24h8" stroke={strokeColor} />
+      <path d="M12 27h8" stroke={strokeColor} />
+      <path d="M13 30h6" stroke={strokeColor} />
+    </svg>
+  );
+}

+ 2 - 4
frontend/src/hooks/useWebSocket.ts

@@ -196,10 +196,8 @@ export function useWebSocket() {
         break;
 
       case 'print_complete':
-        // Refetch printer status when print completes to clear printable_objects_count
-        if (message.printer_id !== undefined) {
-          queryClient.invalidateQueries({ queryKey: ['printerStatus', message.printer_id] });
-        }
+        // Don't invalidate printerStatus here - it causes re-render cascade and browser freeze
+        // The printer_status websocket messages will naturally update the status
         debouncedInvalidate('archives');
         debouncedInvalidate('archiveStats');
         break;

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

@@ -247,14 +247,15 @@ export default {
     telemetryDescription: 'Help improve BamBuddy by sending anonymous usage data',
     telemetryLearnMore: 'Learn more',
     telemetryInfoTitle: 'What data is collected?',
-    telemetryInfoIntro: 'BamBuddy collects minimal anonymous data to help understand how many people use the app and which versions are in use. This helps prioritize bug fixes and new features.',
+    telemetryInfoIntro: 'BamBuddy collects minimal anonymous data to help understand how many people use the app, which versions and printer models are in use. This helps prioritize bug fixes and new features.',
     telemetryInfoCollected: 'What we collect:',
     telemetryInfoItem1: 'A random installation ID (not linked to you or your hardware)',
     telemetryInfoItem2: 'The app version you\'re running',
-    telemetryInfoItem3: 'A timestamp (to count daily/weekly active users)',
+    telemetryInfoItem3: 'Printer model types (e.g., X1C, P1S) - not names or serial numbers',
+    telemetryInfoItem4: 'A timestamp (to count daily/weekly active users)',
     telemetryInfoNotCollected: 'What we do NOT collect:',
-    telemetryInfoNotItem1: 'IP addresses or location data',
-    telemetryInfoNotItem2: 'Printer names, serial numbers, or any printer data',
+    telemetryInfoNotItem1: 'Your IP address is hashed and cannot be reversed',
+    telemetryInfoNotItem2: 'Printer names, serial numbers, or access codes',
     telemetryInfoNotItem3: 'Print history, filenames, or any personal content',
     telemetryInfoNotItem4: 'Any information that could identify you',
     telemetryInfoFooter: 'You can disable telemetry at any time. The installation ID is randomly generated and cannot be traced back to you.',

File diff suppressed because it is too large
+ 814 - 154
frontend/src/pages/ArchivesPage.tsx


+ 46 - 42
frontend/src/pages/CameraPage.tsx

@@ -46,39 +46,36 @@ export function CameraPage() {
   }, [printer]);
 
   // Cleanup on unmount - stop the camera stream
+  // Track if we've already sent the stop signal to avoid duplicate calls
+  const stopSentRef = useRef(false);
+
   useEffect(() => {
     const stopUrl = `/api/v1/printers/${id}/camera/stop`;
+    stopSentRef.current = false;
 
-    // Handle page unload/close with sendBeacon (more reliable than fetch on unload)
-    const handleBeforeUnload = () => {
-      if (id > 0) {
+    const sendStopOnce = () => {
+      if (id > 0 && !stopSentRef.current) {
+        stopSentRef.current = true;
         navigator.sendBeacon(stopUrl);
       }
     };
 
-    // Handle visibility change (tab hidden/closed)
-    const handleVisibilityChange = () => {
-      if (document.visibilityState === 'hidden' && id > 0) {
-        navigator.sendBeacon(stopUrl);
-      }
+    // Handle page unload/close with sendBeacon (more reliable than fetch on unload)
+    const handleBeforeUnload = () => {
+      sendStopOnce();
     };
 
     window.addEventListener('beforeunload', handleBeforeUnload);
-    document.addEventListener('visibilitychange', handleVisibilityChange);
 
     return () => {
       window.removeEventListener('beforeunload', handleBeforeUnload);
-      document.removeEventListener('visibilitychange', handleVisibilityChange);
 
-      // Clear the image source
+      // Clear the image source first to stop the stream
       if (imgRef.current) {
         imgRef.current.src = '';
       }
-      // Call the stop endpoint to terminate ffmpeg processes
-      if (id > 0) {
-        // Use sendBeacon for reliability during unmount
-        navigator.sendBeacon(stopUrl);
-      }
+      // Send stop signal only once
+      sendStopOnce();
     };
   }, [id]);
 
@@ -93,19 +90,31 @@ export function CameraPage() {
     }
   }, [streamMode, streamLoading, imageKey, transitioning]);
 
-  // Fullscreen change listener
+  // Fullscreen change listener - refresh stream after fullscreen transition
   useEffect(() => {
     const handleFullscreenChange = () => {
-      setIsFullscreen(!!document.fullscreenElement);
+      const nowFullscreen = !!document.fullscreenElement;
+      setIsFullscreen(nowFullscreen);
+
+      // Refresh stream after fullscreen transition to prevent stall
+      if (streamMode === 'stream' && !transitioning) {
+        // Clear image src first, then set new key after delay
+        if (imgRef.current) {
+          imgRef.current.src = '';
+        }
+        setTimeout(() => {
+          setStreamLoading(true);
+          setImageKey(Date.now());
+        }, 200);
+      }
     };
     document.addEventListener('fullscreenchange', handleFullscreenChange);
     return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
-  }, []);
+  }, [streamMode, transitioning]);
 
-  // Save window size and position when user resizes or moves (only for popup windows)
+  // Save window size and position when user resizes or moves
+  // Works for both popup windows and standalone camera pages
   useEffect(() => {
-    if (!window.opener) return;
-
     let saveTimeout: NodeJS.Timeout;
     const saveWindowState = () => {
       // Debounce to avoid saving during drag
@@ -121,20 +130,9 @@ export function CameraPage() {
     };
 
     window.addEventListener('resize', saveWindowState);
-    // Use interval to detect position changes (no native 'move' event)
-    const positionInterval = setInterval(() => {
-      const saved = localStorage.getItem('cameraWindowState');
-      if (saved) {
-        const state = JSON.parse(saved);
-        if (state.left !== window.screenX || state.top !== window.screenY) {
-          saveWindowState();
-        }
-      }
-    }, 1000);
 
     return () => {
       clearTimeout(saveTimeout);
-      clearInterval(positionInterval);
       window.removeEventListener('resize', saveWindowState);
     };
   }, []);
@@ -199,8 +197,9 @@ export function CameraPage() {
 
   // Stall detection - periodically check if stream is still receiving frames
   useEffect(() => {
-    if (streamMode !== 'stream' || streamLoading || streamError || isReconnecting || transitioning) {
-      // Clear stall check when not actively streaming
+    // Only skip stall check during initial load, reconnecting, or transitioning
+    // Continue checking even during streamError to detect recovery
+    if (streamMode !== 'stream' || streamLoading || isReconnecting || transitioning) {
       if (stallCheckIntervalRef.current) {
         clearInterval(stallCheckIntervalRef.current);
         stallCheckIntervalRef.current = null;
@@ -214,14 +213,15 @@ export function CameraPage() {
         const response = await fetch(`/api/v1/printers/${id}/camera/status`);
         if (response.ok) {
           const status = await response.json();
-          if (status.stalled) {
-            console.log('Stream stall detected, auto-reconnecting...');
-            // Trigger reconnect
+          // Trigger reconnect if:
+          // 1. Backend reports stall (no frames for 10+ seconds)
+          // 2. OR stream is not active anymore (process died)
+          if (status.stalled || (!status.active && !streamError)) {
+            console.log(`Stream issue detected: stalled=${status.stalled}, active=${status.active}, reconnecting...`);
             if (stallCheckIntervalRef.current) {
               clearInterval(stallCheckIntervalRef.current);
               stallCheckIntervalRef.current = null;
             }
-            // Use the same reconnect logic as stream error
             setStreamLoading(false);
             attemptReconnect();
           }
@@ -263,8 +263,8 @@ export function CameraPage() {
       clearInterval(countdownIntervalRef.current);
     }
 
-    // Auto-resize popup window to fit video content (only if no saved preference)
-    if (window.opener && imgRef.current && !localStorage.getItem('cameraWindowState')) {
+    // Auto-resize window to fit video content (only if no saved preference)
+    if (imgRef.current && !localStorage.getItem('cameraWindowState')) {
       const img = imgRef.current;
       const videoWidth = img.naturalWidth;
       const videoHeight = img.naturalHeight;
@@ -281,7 +281,11 @@ export function CameraPage() {
         const targetWidth = videoWidth + padding + chromeWidth;
         const targetHeight = videoHeight + headerHeight + padding + chromeHeight;
 
-        window.resizeTo(targetWidth, targetHeight);
+        try {
+          window.resizeTo(targetWidth, targetHeight);
+        } catch {
+          // resizeTo may not be allowed in all contexts
+        }
       }
     }
   };

+ 127 - 2
frontend/src/pages/MaintenancePage.tsx

@@ -33,6 +33,7 @@ import {
   Filter,
   CircleDot,
   Printer,
+  ExternalLink,
 } from 'lucide-react';
 import { api } from '../api/client';
 import type { MaintenanceStatus, PrinterMaintenanceOverview, MaintenanceType } from '../api/client';
@@ -110,6 +111,89 @@ function formatIntervalLabel(value: number, type: 'hours' | 'days'): string {
   return `${value}h`;
 }
 
+// Get Bambu Lab wiki URL for a maintenance task based on printer model
+function getMaintenanceWikiUrl(typeName: string, printerModel: string | null): string | null {
+  const model = (printerModel || '').toUpperCase().replace(/[- ]/g, '');
+
+  // Helper to match model families
+  const isX1 = model.includes('X1');
+  const isP1 = model.includes('P1');
+  const isA1Mini = model.includes('A1MINI');
+  const isA1 = model.includes('A1') && !isA1Mini;
+  const isH2D = model.includes('H2D');
+  const isH2C = model.includes('H2C');
+  const isH2S = model.includes('H2S');
+  const isH2 = isH2D || isH2C || isH2S;
+  const isP2S = model.includes('P2S');
+
+  switch (typeName) {
+    case 'Lubricate Linear Rails':
+      if (isX1) return 'https://wiki.bambulab.com/en/x1/maintenance/basic-maintenance';
+      if (isP1) return 'https://wiki.bambulab.com/en/p1/maintenance/p1p-maintenance';
+      if (isA1Mini) return 'https://wiki.bambulab.com/en/a1-mini/maintenance/lubricate-y-axis';
+      if (isA1) return 'https://wiki.bambulab.com/en/a1/maintenance/lubricate-y-axis';
+      if (isH2) return 'https://wiki.bambulab.com/en/h2/maintenance/x-axis-lubrication';
+      if (isP2S) return 'https://wiki.bambulab.com/en/p2s/maintenance/belt-tension'; // P2S maintenance page
+      return 'https://wiki.bambulab.com/en/general/lead-screws-lubrication';
+
+    case 'Clean Nozzle/Hotend':
+      if (isX1 || isP1) return 'https://wiki.bambulab.com/en/x1/troubleshooting/nozzle-clog';
+      if (isA1Mini || isA1) return 'https://wiki.bambulab.com/en/a1-mini/troubleshooting/nozzle-clog';
+      if (isH2) return 'https://wiki.bambulab.com/en/h2/maintenance/nozzl-cold-pull-maintenance-and-cleaning';
+      if (isP2S) return 'https://wiki.bambulab.com/en/p2s/maintenance/cold-pull-maintenance-hotend';
+      return 'https://wiki.bambulab.com/en/x1/troubleshooting/nozzle-clog';
+
+    case 'Check Belt Tension':
+      if (isX1) return 'https://wiki.bambulab.com/en/x1/maintenance/belt-tension';
+      if (isP1) return 'https://wiki.bambulab.com/en/p1/maintenance/p1p-maintenance';
+      if (isA1Mini) return 'https://wiki.bambulab.com/en/a1-mini/maintenance/belt_tension';
+      if (isA1) return 'https://wiki.bambulab.com/en/a1/maintenance/belt_tension';
+      if (isH2D) return 'https://wiki.bambulab.com/en/h2/maintenance/belt-tension';
+      if (isH2C) return 'https://wiki.bambulab.com/en/h2c/maintenance/belt-tension';
+      if (isH2S) return 'https://wiki.bambulab.com/en/h2s/maintenance/belt-tension';
+      if (isP2S) return 'https://wiki.bambulab.com/en/p2s/maintenance/belt-tension';
+      return 'https://wiki.bambulab.com/en/x1/maintenance/belt-tension';
+
+    case 'Clean Carbon Rods':
+      // Only X1 and P1 series have carbon rods
+      if (isX1 || isP1) return 'https://wiki.bambulab.com/en/general/carbon-rods-clearance';
+      // A1, H2, P2S don't have carbon rods - return null
+      if (isA1Mini || isA1 || isH2 || isP2S) return null;
+      return 'https://wiki.bambulab.com/en/general/carbon-rods-clearance';
+
+    case 'Clean Build Plate':
+      // Same for all printers
+      return 'https://wiki.bambulab.com/en/filament-acc/acc/pei-plate-clean-guide';
+
+    case 'Check PTFE Tube':
+      if (isX1 || isP1) return 'https://wiki.bambulab.com/en/x1/maintenance/replace-ptfe-tube';
+      if (isA1Mini || isA1) return 'https://wiki.bambulab.com/en/a1-mini/maintenance/ptfe-tube';
+      if (isH2D) return 'https://wiki.bambulab.com/en/h2/maintenance/replace-ptfe-tube-on-h2d-printer';
+      if (isH2S) return 'https://wiki.bambulab.com/en/h2s/maintenance/replace-ptfe-tube-on-h2s-printer';
+      if (isH2C) return 'https://wiki.bambulab.com/en/h2/maintenance/replace-ptfe-tube-on-h2d-printer'; // H2C uses H2D guide
+      if (isP2S) return 'https://wiki.bambulab.com/en/x1/maintenance/replace-ptfe-tube'; // P2S uses similar PTFE
+      return 'https://wiki.bambulab.com/en/x1/maintenance/replace-ptfe-tube';
+
+    case 'Replace HEPA Filter':
+    case 'HEPA Filter':
+    case 'Replace Carbon Filter':
+    case 'Carbon Filter':
+      if (isH2) return 'https://wiki.bambulab.com/en/h2/maintenance/replace-smoke-purifier-air-filte';
+      // X1/P1 use the activated carbon filter
+      return 'https://wiki.bambulab.com/en/x1/maintenance/replace-carbon-filter';
+
+    case 'Lubricate Left Nozzle Rail':
+    case 'Left Nozzle Rail':
+      // H2 series specific - dual nozzle system
+      if (isH2) return 'https://wiki.bambulab.com/en/h2/maintenance/x-axis-lubrication';
+      return null;
+
+    default:
+      // Custom maintenance types don't have wiki URLs
+      return null;
+  }
+}
+
 // Maintenance item card - cleaner, more visual design
 function MaintenanceCard({
   item,
@@ -200,6 +284,23 @@ function MaintenanceCard({
                 <Calendar className="w-3.5 h-3.5 text-bambu-gray shrink-0" />
               </span>
             )}
+            {/* Wiki link - next to name */}
+            {(() => {
+              // Use custom wiki_url from type if available, otherwise use computed URL
+              const wikiUrl = item.maintenance_type_wiki_url || getMaintenanceWikiUrl(item.maintenance_type_name, item.printer_model);
+              return wikiUrl ? (
+                <a
+                  href={wikiUrl}
+                  target="_blank"
+                  rel="noopener noreferrer"
+                  className="text-bambu-gray hover:text-bambu-green transition-colors shrink-0"
+                  title="View documentation"
+                  onClick={(e) => e.stopPropagation()}
+                >
+                  <ExternalLink className="w-3.5 h-3.5" />
+                </a>
+              ) : null;
+            })()}
           </div>
 
           {/* Progress bar */}
@@ -418,8 +519,8 @@ function SettingsSection({
   overview: PrinterMaintenanceOverview[] | undefined;
   types: MaintenanceType[];
   onUpdateInterval: (id: number, data: { custom_interval_hours?: number | null; custom_interval_type?: 'hours' | 'days' | null }) => void;
-  onAddType: (data: { name: string; description?: string; default_interval_hours: number; interval_type: 'hours' | 'days'; icon?: string }, printerIds: number[]) => void;
-  onUpdateType: (id: number, data: { name?: string; default_interval_hours?: number; interval_type?: 'hours' | 'days'; icon?: string }) => void;
+  onAddType: (data: { name: string; description?: string; default_interval_hours: number; interval_type: 'hours' | 'days'; icon?: string; wiki_url?: string | null }, printerIds: number[]) => void;
+  onUpdateType: (id: number, data: { name?: string; default_interval_hours?: number; interval_type?: 'hours' | 'days'; icon?: string; wiki_url?: string | null }) => void;
   onDeleteType: (id: number) => void;
   onAssignType: (printerId: number, typeId: number) => void;
   onRemoveItem: (itemId: number) => void;
@@ -432,6 +533,7 @@ function SettingsSection({
   const [newTypeInterval, setNewTypeInterval] = useState('100');
   const [newTypeIntervalType, setNewTypeIntervalType] = useState<'hours' | 'days'>('hours');
   const [newTypeIcon, setNewTypeIcon] = useState('Wrench');
+  const [newTypeWikiUrl, setNewTypeWikiUrl] = useState('');
   const [selectedPrinters, setSelectedPrinters] = useState<Set<number>>(new Set());
   const [expandedType, setExpandedType] = useState<number | null>(null);
 
@@ -466,6 +568,7 @@ function SettingsSection({
   const [editTypeInterval, setEditTypeInterval] = useState('');
   const [editTypeIntervalType, setEditTypeIntervalType] = useState<'hours' | 'days'>('hours');
   const [editTypeIcon, setEditTypeIcon] = useState('Wrench');
+  const [editTypeWikiUrl, setEditTypeWikiUrl] = useState('');
 
   const startEditType = (type: MaintenanceType) => {
     setEditingType(type);
@@ -473,6 +576,7 @@ function SettingsSection({
     setEditTypeInterval(type.default_interval_hours.toString());
     setEditTypeIntervalType(type.interval_type || 'hours');
     setEditTypeIcon(type.icon || 'Wrench');
+    setEditTypeWikiUrl(type.wiki_url || '');
   };
 
   const handleSaveEditType = () => {
@@ -482,6 +586,7 @@ function SettingsSection({
         default_interval_hours: parseFloat(editTypeInterval),
         interval_type: editTypeIntervalType,
         icon: editTypeIcon,
+        wiki_url: editTypeWikiUrl.trim() || null,
       });
       setEditingType(null);
     }
@@ -508,10 +613,12 @@ function SettingsSection({
         default_interval_hours: parseFloat(newTypeInterval),
         interval_type: newTypeIntervalType,
         icon: newTypeIcon,
+        wiki_url: newTypeWikiUrl.trim() || null,
       }, Array.from(selectedPrinters));
       setNewTypeName('');
       setNewTypeInterval('100');
       setNewTypeIntervalType('hours');
+      setNewTypeWikiUrl('');
       setSelectedPrinters(new Set());
       setShowAddType(false);
     }
@@ -626,6 +733,17 @@ function SettingsSection({
                     </div>
                   </div>
                 </div>
+                {/* Wiki URL */}
+                <div className="mt-4">
+                  <label className="block text-xs text-bambu-gray mb-1.5">Documentation Link (optional)</label>
+                  <input
+                    type="url"
+                    value={newTypeWikiUrl}
+                    onChange={(e) => setNewTypeWikiUrl(e.target.value)}
+                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
+                    placeholder="https://wiki.bambulab.com/..."
+                  />
+                </div>
                 {/* Printer selection */}
                 <div className="mt-4">
                   <label className="block text-xs text-bambu-gray mb-1.5">Assign to Printers</label>
@@ -739,6 +857,13 @@ function SettingsSection({
                         );
                       })}
                     </div>
+                    <input
+                      type="url"
+                      value={editTypeWikiUrl}
+                      onChange={(e) => setEditTypeWikiUrl(e.target.value)}
+                      className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
+                      placeholder="Documentation link (optional)"
+                    />
                     <div className="flex gap-2">
                       <Button size="sm" onClick={handleSaveEditType} disabled={!editTypeName.trim()}>
                         Save

+ 376 - 79
frontend/src/pages/PrintersPage.tsx

@@ -23,8 +23,6 @@ import {
   Pencil,
   ArrowUp,
   ArrowDown,
-  LayoutGrid,
-  LayoutList,
   Layers,
   Video,
   Search,
@@ -37,7 +35,7 @@ import {
   Fan,
   Wind,
   AirVent,
-  Minus,
+  Download,
 } from 'lucide-react';
 
 // Custom Skip Objects icon - arrow jumping over boxes
@@ -53,8 +51,9 @@ const SkipObjectsIcon = ({ className }: { className?: string }) => (
   </svg>
 );
 import { useNavigate } from 'react-router-dom';
-import { api, discoveryApi } from '../api/client';
-import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter } from '../api/client';
+import { api, discoveryApi, firmwareApi } from '../api/client';
+import { formatDateOnly } from '../utils/date';
+import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
@@ -66,6 +65,7 @@ import { AMSHistoryModal } from '../components/AMSHistoryModal';
 import { FilamentHoverCard, EmptySlotHoverCard } from '../components/FilamentHoverCard';
 import { LinkSpoolModal } from '../components/LinkSpoolModal';
 import { useToast } from '../contexts/ToastContext';
+import { ChamberLight } from '../components/icons/ChamberLight';
 
 // Complete Bambu Lab filament color mapping by tray_id_name
 // Source: https://github.com/queengooborg/Bambu-Lab-RFID-Library
@@ -644,7 +644,7 @@ function formatTime(seconds: number): string {
   return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
 }
 
-function formatETA(remainingMinutes: number): string {
+function formatETA(remainingMinutes: number, timeFormat: 'system' | '12h' | '24h' = 'system'): string {
   const now = new Date();
   const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000);
   const today = new Date();
@@ -652,7 +652,16 @@ function formatETA(remainingMinutes: number): string {
   const etaDay = new Date(eta);
   etaDay.setHours(0, 0, 0, 0);
 
-  const timeStr = eta.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+  // Build time format options based on setting
+  const timeOptions: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' };
+  if (timeFormat === '12h') {
+    timeOptions.hour12 = true;
+  } else if (timeFormat === '24h') {
+    timeOptions.hour12 = false;
+  }
+  // 'system' leaves hour12 undefined, letting the browser decide
+
+  const timeStr = eta.toLocaleTimeString([], timeOptions);
 
   // Check if it's tomorrow or later
   const dayDiff = Math.floor((etaDay.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
@@ -861,14 +870,17 @@ function PrinterCard({
   hideIfDisconnected,
   maintenanceInfo,
   viewMode = 'expanded',
+  cardSize = 2,
   amsThresholds,
   spoolmanEnabled = false,
   hasUnlinkedSpools = false,
+  timeFormat = 'system',
 }: {
   printer: Printer;
   hideIfDisconnected?: boolean;
   maintenanceInfo?: PrinterMaintenanceInfo;
   viewMode?: ViewMode;
+  cardSize?: number;
   amsThresholds?: {
     humidityGood: number;
     humidityFair: number;
@@ -877,6 +889,7 @@ function PrinterCard({
   };
   spoolmanEnabled?: boolean;
   hasUnlinkedSpools?: boolean;
+  timeFormat?: 'system' | '12h' | '24h';
 }) {
   const queryClient = useQueryClient();
   const navigate = useNavigate();
@@ -903,6 +916,7 @@ function PrinterCard({
     trayUuid: string;
     trayInfo: { type: string; color: string; location: string };
   } | null>(null);
+  const [showFirmwareModal, setShowFirmwareModal] = useState(false);
 
   const { data: status } = useQuery({
     queryKey: ['printerStatus', printer.id],
@@ -910,6 +924,14 @@ function PrinterCard({
     refetchInterval: 30000, // Fallback polling, WebSocket handles real-time
   });
 
+  // Check for firmware updates (cached for 5 minutes)
+  const { data: firmwareInfo } = useQuery({
+    queryKey: ['firmwareUpdate', printer.id],
+    queryFn: () => firmwareApi.checkPrinterUpdate(printer.id),
+    staleTime: 5 * 60 * 1000,
+    refetchInterval: 5 * 60 * 1000,
+  });
+
   // Collect unique tray_info_idx values for cloud filament info lookup
   const trayInfoIds = useMemo(() => {
     const ids = new Set<string>();
@@ -1025,6 +1047,7 @@ function PrinterCard({
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['printers'] });
       queryClient.invalidateQueries({ queryKey: ['archives'] });
+      queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
     },
   });
 
@@ -1082,6 +1105,33 @@ function PrinterCard({
     onError: (error: Error) => showToast(error.message || 'Failed to resume print', 'error'),
   });
 
+  // Chamber light mutation with optimistic update
+  const chamberLightMutation = useMutation({
+    mutationFn: (on: boolean) => api.setChamberLight(printer.id, on),
+    onMutate: async (on) => {
+      // Cancel any outgoing refetches
+      await queryClient.cancelQueries({ queryKey: ['printerStatus', printer.id] });
+      // Snapshot the previous value
+      const previousStatus = queryClient.getQueryData(['printerStatus', printer.id]);
+      // Optimistically update
+      queryClient.setQueryData(['printerStatus', printer.id], (old: typeof status) => ({
+        ...old,
+        chamber_light: on,
+      }));
+      return { previousStatus };
+    },
+    onSuccess: (_, on) => {
+      showToast(`Chamber light ${on ? 'on' : 'off'}`);
+    },
+    onError: (error: Error, _, context) => {
+      // Rollback on error
+      if (context?.previousStatus) {
+        queryClient.setQueryData(['printerStatus', printer.id], context.previousStatus);
+      }
+      showToast(error.message || 'Failed to control chamber light', 'error');
+    },
+  });
+
   // Query for printable objects (for skip functionality)
   // Fetch when printing with 2+ objects OR when modal is open
   const isPrintingWithObjects = (status?.state === 'RUNNING' || status?.state === 'PAUSE' || status?.state === 'PAUSED') && (status?.printable_objects_count ?? 0) >= 2;
@@ -1199,11 +1249,40 @@ function PrinterCard({
     return null;
   }
 
+  // Size-based styling helpers
+  const getImageSize = () => {
+    switch (cardSize) {
+      case 1: return 'w-10 h-10';
+      case 2: return 'w-14 h-14';
+      case 3: return 'w-16 h-16';
+      case 4: return 'w-20 h-20';
+      default: return 'w-14 h-14';
+    }
+  };
+  const getTitleSize = () => {
+    switch (cardSize) {
+      case 1: return 'text-base truncate';
+      case 2: return 'text-lg';
+      case 3: return 'text-xl';
+      case 4: return 'text-2xl';
+      default: return 'text-lg';
+    }
+  };
+  const getSpacing = () => {
+    switch (cardSize) {
+      case 1: return 'mb-2';
+      case 2: return 'mb-4';
+      case 3: return 'mb-5';
+      case 4: return 'mb-6';
+      default: return 'mb-4';
+    }
+  };
+
   return (
     <Card className="relative">
-      <CardContent>
+      <CardContent className={cardSize >= 3 ? 'p-5' : ''}>
         {/* Header */}
-        <div className={viewMode === 'compact' ? 'mb-2' : 'mb-4'}>
+        <div className={getSpacing()}>
           {/* Top row: Image, Name, Menu */}
           <div className="flex items-start justify-between gap-2">
             <div className="flex items-center gap-3 min-w-0 flex-1">
@@ -1211,11 +1290,11 @@ function PrinterCard({
               <img
                 src={getPrinterImage(printer.model)}
                 alt={printer.model || 'Printer'}
-                className={`object-contain rounded-lg bg-bambu-dark flex-shrink-0 ${viewMode === 'compact' ? 'w-10 h-10' : 'w-14 h-14'}`}
+                className={`object-contain rounded-lg bg-bambu-dark flex-shrink-0 ${getImageSize()}`}
               />
               <div className="min-w-0 flex-1">
                 <div className="flex items-center gap-2">
-                  <h3 className={`font-semibold text-white ${viewMode === 'compact' ? 'text-base truncate' : 'text-lg'}`}>{printer.name}</h3>
+                  <h3 className={`font-semibold text-white ${getTitleSize()}`}>{printer.name}</h3>
                   {/* Connection indicator dot for compact mode */}
                   {viewMode === 'compact' && (
                     <div
@@ -1391,6 +1470,17 @@ function PrinterCard({
                   {queueCount}
                 </button>
               )}
+              {/* Firmware Update Badge */}
+              {firmwareInfo?.update_available && (
+                <button
+                  onClick={() => setShowFirmwareModal(true)}
+                  className="flex items-center gap-1 px-2 py-1 rounded-full text-xs bg-orange-500/20 text-orange-400 hover:opacity-80 transition-opacity"
+                  title={`Firmware update available: ${firmwareInfo.current_version} → ${firmwareInfo.latest_version}`}
+                >
+                  <Download className="w-3 h-3" />
+                  Update
+                </button>
+              )}
             </div>
           )}
         </div>
@@ -1538,7 +1628,7 @@ function PrinterCard({
                                   {formatTime(status.remaining_time * 60)}
                                 </span>
                                 <span className="text-bambu-green font-medium" title="Estimated completion time">
-                                  ETA {formatETA(status.remaining_time)}
+                                  ETA {formatETA(status.remaining_time, timeFormat)}
                                 </span>
                               </>
                             )}
@@ -1567,7 +1657,7 @@ function PrinterCard({
                               Last: {lastPrint.print_name || lastPrint.filename}
                               {lastPrint.completed_at && (
                                 <span className="ml-1 text-bambu-gray/60">
-                                  • {new Date(lastPrint.completed_at).toLocaleDateString([], { month: 'short', day: 'numeric' })}
+                                  • {formatDateOnly(lastPrint.completed_at, { month: 'short', day: 'numeric' })}
                                 </span>
                               )}
                             </p>
@@ -2309,6 +2399,18 @@ function PrinterCard({
               <p className="truncate">{printer.serial_number}</p>
             </div>
             <div className="flex items-center gap-2">
+              {/* Chamber Light Toggle */}
+              <Button
+                variant="secondary"
+                size="sm"
+                onClick={() => chamberLightMutation.mutate(!status?.chamber_light)}
+                disabled={!status?.connected || chamberLightMutation.isPending}
+                title={status?.chamber_light ? 'Turn off chamber light' : 'Turn on chamber light'}
+                className={status?.chamber_light ? 'bg-yellow-500/20 hover:bg-yellow-500/30 border-yellow-500/30' : ''}
+              >
+                <ChamberLight on={status?.chamber_light ?? false} className="w-4 h-4" />
+              </Button>
+              {/* Camera Button */}
               <Button
                 variant="secondary"
                 size="sm"
@@ -2321,7 +2423,7 @@ function PrinterCard({
                     `height=${state.height}`,
                     state.left !== undefined ? `left=${state.left}` : '',
                     state.top !== undefined ? `top=${state.top}` : '',
-                    'menubar=no,toolbar=no,location=no,status=no',
+                    'menubar=no,toolbar=no,location=no,status=no,noopener',
                   ].filter(Boolean).join(',');
                   window.open(`/camera/${printer.id}`, `camera-${printer.id}`, features);
                 }}
@@ -2530,30 +2632,31 @@ function PrinterCard({
 
                           // Use position data if available, otherwise fall back to grid
                           if (obj.x != null && obj.y != null && objectsData.bbox_all) {
-                            // bbox_all is [x_min, y_min, x_max, y_max] - the bounds of all objects
-                            // The top_N.png image is rendered to show this area with ~10% padding
+                            // bbox_all defines the visible area in the top_N.png image
+                            // Format: [x_min, y_min, x_max, y_max] in mm
                             const [xMin, yMin, xMax, yMax] = objectsData.bbox_all;
                             const bboxWidth = xMax - xMin;
                             const bboxHeight = yMax - yMin;
 
-                            // Calculate position relative to bbox, with padding
-                            // The image has roughly 10% padding on each side
-                            const padding = 10;
+                            // The image shows bbox_all area with some padding (~5-10%)
+                            const padding = 8;
                             const contentArea = 100 - (padding * 2);
 
+                            // Map object position to image percentage
                             x = padding + ((obj.x - xMin) / bboxWidth) * contentArea;
-                            // Y axis: in 3D coords Y increases toward back, in image Y increases down
-                            // So we need to flip: high Y in 3D = low Y in image (top)
+                            // Y axis: image Y increases downward, but 3D Y increases toward back
                             y = padding + ((yMax - obj.y) / bboxHeight) * contentArea;
 
                             // Clamp to valid range
                             x = Math.max(5, Math.min(95, x));
                             y = Math.max(5, Math.min(95, y));
                           } else if (obj.x != null && obj.y != null) {
-                            // Fallback: use full build plate (256mm for X1C)
-                            const buildPlateSize = 256;
-                            x = Math.max(10, Math.min(90, (obj.x / buildPlateSize) * 100));
-                            y = Math.max(10, Math.min(90, 100 - (obj.y / buildPlateSize) * 100));
+                            // Fallback: use full build plate (256mm)
+                            const buildPlate = 256;
+                            x = (obj.x / buildPlate) * 100;
+                            y = 100 - (obj.y / buildPlate) * 100;
+                            x = Math.max(5, Math.min(95, x));
+                            y = Math.max(5, Math.min(95, y));
                           } else {
                             // Fallback: arrange in a grid pattern over the build plate area
                             const cols = Math.ceil(Math.sqrt(objectsData.objects.length));
@@ -2697,6 +2800,15 @@ function PrinterCard({
         />
       )}
 
+      {/* Firmware Update Modal */}
+      {showFirmwareModal && firmwareInfo && (
+        <FirmwareUpdateModal
+          printer={printer}
+          firmwareInfo={firmwareInfo}
+          onClose={() => setShowFirmwareModal(false)}
+        />
+      )}
+
       {/* AMS Slot Menu Backdrop - closes menu when clicking outside */}
       {amsSlotMenu && (
         <div
@@ -3087,6 +3199,206 @@ function AddPrinterModal({
   );
 }
 
+function FirmwareUpdateModal({
+  printer,
+  firmwareInfo,
+  onClose,
+}: {
+  printer: Printer;
+  firmwareInfo: FirmwareUpdateInfo;
+  onClose: () => void;
+}) {
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const [uploadStatus, setUploadStatus] = useState<FirmwareUploadStatus | null>(null);
+  const [isUploading, setIsUploading] = useState(false);
+  const [pollInterval, setPollInterval] = useState<NodeJS.Timeout | null>(null);
+
+  // Prepare check query
+  const { data: prepareInfo, isLoading: isPreparing } = useQuery({
+    queryKey: ['firmwarePrepare', printer.id],
+    queryFn: () => firmwareApi.prepareUpload(printer.id),
+    staleTime: 30000,
+  });
+
+  // Start upload mutation
+  const uploadMutation = useMutation({
+    mutationFn: () => firmwareApi.startUpload(printer.id),
+    onSuccess: () => {
+      setIsUploading(true);
+      // Start polling for status
+      const interval = setInterval(async () => {
+        try {
+          const status = await firmwareApi.getUploadStatus(printer.id);
+          setUploadStatus(status);
+          if (status.status === 'complete' || status.status === 'error') {
+            clearInterval(interval);
+            setPollInterval(null);
+            setIsUploading(false);
+            if (status.status === 'complete') {
+              showToast('Firmware uploaded! Trigger update from printer screen.', 'success');
+              queryClient.invalidateQueries({ queryKey: ['firmwareUpdate', printer.id] });
+            }
+          }
+        } catch {
+          // Ignore errors during polling
+        }
+      }, 2000);
+      setPollInterval(interval);
+    },
+    onError: (error: Error) => {
+      showToast(`Failed to start upload: ${error.message}`, 'error');
+      setIsUploading(false);
+    },
+  });
+
+  // Cleanup on unmount
+  useEffect(() => {
+    return () => {
+      if (pollInterval) clearInterval(pollInterval);
+    };
+  }, [pollInterval]);
+
+  const handleStartUpload = () => {
+    setUploadStatus(null);
+    uploadMutation.mutate();
+  };
+
+  return (
+    <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
+      <Card className="w-full max-w-md mx-4">
+        <CardContent>
+          <div className="flex items-start gap-3 mb-4">
+            <div className="p-2 rounded-full bg-orange-500/20">
+              <Download className="w-5 h-5 text-orange-400" />
+            </div>
+            <div className="flex-1">
+              <h3 className="text-lg font-semibold text-white">Firmware Update</h3>
+              <p className="text-sm text-bambu-gray mt-1">
+                {printer.name}
+              </p>
+            </div>
+          </div>
+
+          {/* Version Info */}
+          <div className="bg-bambu-dark rounded-lg p-3 mb-4">
+            <div className="flex justify-between items-center text-sm">
+              <span className="text-bambu-gray">Current:</span>
+              <span className="text-white font-mono">{firmwareInfo.current_version || 'Unknown'}</span>
+            </div>
+            <div className="flex justify-between items-center text-sm mt-1">
+              <span className="text-bambu-gray">Latest:</span>
+              <span className="text-orange-400 font-mono">{firmwareInfo.latest_version}</span>
+            </div>
+            {firmwareInfo.release_notes && (
+              <details className="mt-3 text-sm">
+                <summary className="text-orange-400 cursor-pointer hover:underline">
+                  Release Notes
+                </summary>
+                <div className="mt-2 text-bambu-gray text-xs max-h-40 overflow-y-auto whitespace-pre-wrap">
+                  {firmwareInfo.release_notes}
+                </div>
+              </details>
+            )}
+          </div>
+
+          {/* Status / Progress */}
+          {isPreparing ? (
+            <div className="flex items-center gap-2 text-bambu-gray text-sm mb-4">
+              <Loader2 className="w-4 h-4 animate-spin" />
+              Checking prerequisites...
+            </div>
+          ) : prepareInfo && !isUploading && !uploadStatus ? (
+            <div className="mb-4">
+              {prepareInfo.can_proceed ? (
+                <div className="flex items-center gap-2 text-bambu-green text-sm">
+                  <Box className="w-4 h-4" />
+                  SD card ready. Click below to upload firmware.
+                </div>
+              ) : (
+                <div className="space-y-1">
+                  {prepareInfo.errors.map((error, i) => (
+                    <div key={i} className="flex items-center gap-2 text-red-400 text-sm">
+                      <AlertCircle className="w-4 h-4 flex-shrink-0" />
+                      {error}
+                    </div>
+                  ))}
+                </div>
+              )}
+            </div>
+          ) : null}
+
+          {/* Upload Progress */}
+          {(isUploading || uploadStatus) && uploadStatus && (
+            <div className="mb-4">
+              <div className="flex items-center justify-between text-sm mb-1">
+                <span className="text-bambu-gray capitalize">{uploadStatus.status}</span>
+                <span className="text-white">{uploadStatus.progress}%</span>
+              </div>
+              <div className="w-full bg-bambu-dark-tertiary rounded-full h-2">
+                <div
+                  className={`h-2 rounded-full transition-all ${
+                    uploadStatus.status === 'error' ? 'bg-red-500' :
+                    uploadStatus.status === 'complete' ? 'bg-bambu-green' : 'bg-orange-500'
+                  } ${uploadStatus.status === 'uploading' ? 'animate-pulse' : ''}`}
+                  style={{ width: `${uploadStatus.progress}%` }}
+                />
+              </div>
+              <p className="text-xs text-bambu-gray mt-1">{uploadStatus.message}</p>
+              {uploadStatus.error && (
+                <p className="text-xs text-red-400 mt-1">{uploadStatus.error}</p>
+              )}
+            </div>
+          )}
+
+          {/* Success Message */}
+          {uploadStatus?.status === 'complete' && (
+            <div className="bg-bambu-green/10 border border-bambu-green/30 rounded-lg p-3 mb-4">
+              <p className="text-sm text-bambu-green font-medium mb-2">
+                Firmware uploaded to SD card!
+              </p>
+              <p className="text-xs text-bambu-gray">
+                To apply the update on your printer:
+              </p>
+              <ol className="text-xs text-bambu-gray mt-1 list-decimal list-inside space-y-1">
+                <li>On the printer's touchscreen, go to <strong className="text-white">Settings</strong></li>
+                <li>Navigate to <strong className="text-white">Firmware</strong></li>
+                <li>Select <strong className="text-white">Update from SD card</strong></li>
+                <li>The update will take 10-20 minutes</li>
+              </ol>
+            </div>
+          )}
+
+          {/* Buttons */}
+          <div className="flex gap-2 justify-end">
+            <Button variant="secondary" onClick={onClose}>
+              {uploadStatus?.status === 'complete' ? 'Done' : 'Cancel'}
+            </Button>
+            {prepareInfo?.can_proceed && !isUploading && uploadStatus?.status !== 'complete' && (
+              <Button
+                onClick={handleStartUpload}
+                disabled={uploadMutation.isPending}
+              >
+                {uploadMutation.isPending ? (
+                  <>
+                    <Loader2 className="w-4 h-4 animate-spin mr-2" />
+                    Starting...
+                  </>
+                ) : (
+                  <>
+                    <Download className="w-4 h-4 mr-2" />
+                    Upload Firmware
+                  </>
+                )}
+              </Button>
+            )}
+          </div>
+        </CardContent>
+      </Card>
+    </div>
+  );
+}
+
 function EditPrinterModal({
   printer,
   onClose,
@@ -3338,14 +3650,13 @@ export function PrintersPage() {
   const [sortAsc, setSortAsc] = useState<boolean>(() => {
     return localStorage.getItem('printerSortAsc') !== 'false';
   });
-  const [viewMode, setViewMode] = useState<ViewMode>(() => {
-    return (localStorage.getItem('printerViewMode') as ViewMode) || 'expanded';
-  });
   // Card size: 1=small, 2=medium, 3=large, 4=xl
   const [cardSize, setCardSize] = useState<number>(() => {
     const saved = localStorage.getItem('printerCardSize');
     return saved ? parseInt(saved, 10) : 2; // Default to medium
   });
+  // Derive viewMode from cardSize: S=compact, M/L/XL=expanded
+  const viewMode: ViewMode = cardSize === 1 ? 'compact' : 'expanded';
   const queryClient = useQueryClient();
 
   const { data: printers, isLoading } = useQuery({
@@ -3417,6 +3728,7 @@ export function PrintersPage() {
     mutationFn: api.createPrinter,
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['printers'] });
+      queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
       setShowAddModal(false);
     },
   });
@@ -3449,26 +3761,14 @@ export function PrintersPage() {
     localStorage.setItem('printerSortAsc', String(newAsc));
   };
 
-  const toggleViewMode = () => {
-    const newMode = viewMode === 'expanded' ? 'compact' : 'expanded';
-    setViewMode(newMode);
-    localStorage.setItem('printerViewMode', newMode);
-  };
-
-  const changeCardSize = (delta: number) => {
-    const newSize = Math.max(1, Math.min(4, cardSize + delta));
-    setCardSize(newSize);
-    localStorage.setItem('printerCardSize', String(newSize));
-  };
-
   // Grid classes based on card size (1=small, 2=medium, 3=large, 4=xl)
   const getGridClasses = () => {
     switch (cardSize) {
-      case 1: return 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5';
-      case 2: return 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3';
-      case 3: return 'grid-cols-1 md:grid-cols-2';
-      case 4: return 'grid-cols-1 max-w-3xl';
-      default: return 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3';
+      case 1: return 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5'; // S: many small cards
+      case 2: return 'grid-cols-1 md:grid-cols-2 xl:grid-cols-3'; // M: medium cards
+      case 3: return 'grid-cols-1 lg:grid-cols-2'; // L: large cards, 2 columns max
+      case 4: return 'grid-cols-1'; // XL: single column, full width
+      default: return 'grid-cols-1 md:grid-cols-2 xl:grid-cols-3';
     }
   };
 
@@ -3567,40 +3867,33 @@ export function PrintersPage() {
             </button>
           </div>
 
-          {/* View mode toggle */}
-          <button
-            onClick={toggleViewMode}
-            className="p-1.5 rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
-            title={viewMode === 'expanded' ? 'Switch to compact view' : 'Switch to expanded view'}
-          >
-            {viewMode === 'expanded' ? (
-              <LayoutList className="w-5 h-5 text-bambu-gray" />
-            ) : (
-              <LayoutGrid className="w-5 h-5 text-bambu-gray" />
-            )}
-          </button>
-
-          {/* Card size control */}
-          <div className="flex items-center gap-1 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
-            <button
-              onClick={() => changeCardSize(-1)}
-              disabled={cardSize <= 1}
-              className="p-1.5 rounded-l-lg hover:bg-bambu-dark-tertiary transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
-              title="Smaller cards"
-            >
-              <Minus className="w-4 h-4 text-bambu-gray" />
-            </button>
-            <span className="text-xs text-bambu-gray w-6 text-center font-medium">
-              {cardSizeLabels[cardSize - 1]}
-            </span>
-            <button
-              onClick={() => changeCardSize(1)}
-              disabled={cardSize >= 4}
-              className="p-1.5 rounded-r-lg hover:bg-bambu-dark-tertiary transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
-              title="Larger cards"
-            >
-              <Plus className="w-4 h-4 text-bambu-gray" />
-            </button>
+          {/* Card size selector */}
+          <div className="flex items-center bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
+            {cardSizeLabels.map((label, index) => {
+              const size = index + 1;
+              const isSelected = cardSize === size;
+              return (
+                <button
+                  key={label}
+                  onClick={() => {
+                    setCardSize(size);
+                    localStorage.setItem('printerCardSize', String(size));
+                  }}
+                  className={`px-2 py-1.5 text-xs font-medium transition-colors ${
+                    index === 0 ? 'rounded-l-lg' : ''
+                  } ${
+                    index === cardSizeLabels.length - 1 ? 'rounded-r-lg' : ''
+                  } ${
+                    isSelected
+                      ? 'bg-bambu-green text-white'
+                      : 'text-bambu-gray hover:bg-bambu-dark-tertiary hover:text-white'
+                  }`}
+                  title={`${label === 'S' ? 'Small' : label === 'M' ? 'Medium' : label === 'L' ? 'Large' : 'Extra large'} cards`}
+                >
+                  {label}
+                </button>
+              );
+            })}
           </div>
 
           <div className="w-px h-6 bg-bambu-dark-tertiary" />
@@ -3695,6 +3988,7 @@ export function PrintersPage() {
                     hideIfDisconnected={hideDisconnected}
                     maintenanceInfo={maintenanceByPrinter[printer.id]}
                     viewMode={viewMode}
+                    cardSize={cardSize}
                     amsThresholds={settings ? {
                       humidityGood: Number(settings.ams_humidity_good) || 40,
                       humidityFair: Number(settings.ams_humidity_fair) || 60,
@@ -3703,6 +3997,7 @@ export function PrintersPage() {
                     } : undefined}
                     spoolmanEnabled={spoolmanEnabled}
                     hasUnlinkedSpools={hasUnlinkedSpools}
+                    timeFormat={settings?.time_format || 'system'}
                   />
                 ))}
               </div>
@@ -3719,6 +4014,7 @@ export function PrintersPage() {
               hideIfDisconnected={hideDisconnected}
               maintenanceInfo={maintenanceByPrinter[printer.id]}
               viewMode={viewMode}
+              cardSize={cardSize}
               spoolmanEnabled={spoolmanEnabled}
               hasUnlinkedSpools={hasUnlinkedSpools}
               amsThresholds={settings ? {
@@ -3727,6 +4023,7 @@ export function PrintersPage() {
                 tempGood: Number(settings.ams_temp_good) || 28,
                 tempFair: Number(settings.ams_temp_fair) || 35,
               } : undefined}
+              timeFormat={settings?.time_format || 'system'}
             />
           ))}
         </div>

+ 3 - 1
frontend/src/pages/ProfilesPage.tsx

@@ -41,6 +41,7 @@ import {
   Plus as PlusIcon,
 } from 'lucide-react';
 import { api } from '../api/client';
+import { parseUTCDate } from '../utils/date';
 import type { SlicerSetting, SlicerSettingsResponse, SlicerSettingDetail, SlicerSettingCreate, Printer, FieldDefinition } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
@@ -86,7 +87,8 @@ function isUserPreset(settingId: string): boolean {
 
 // Format relative time
 function formatRelativeTime(dateStr: string): string {
-  const date = new Date(dateStr);
+  const date = parseUTCDate(dateStr);
+  if (!date) return '';
   const now = new Date();
   const diffMs = now.getTime() - date.getTime();
   const diffMins = Math.floor(diffMs / 60000);

+ 6 - 5
frontend/src/pages/ProjectDetailPage.tsx

@@ -32,6 +32,7 @@ import {
   ShoppingCart,
 } from 'lucide-react';
 import { api } from '../api/client';
+import { parseUTCDate, formatDateOnly, formatDateTime, type TimeFormat } from '../utils/date';
 import type { Archive, ProjectUpdate, BOMItem, BOMItemCreate } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
@@ -175,13 +176,13 @@ function PriorityBadge({ priority }: { priority: string }) {
 
 function formatDate(dateString: string | null): string {
   if (!dateString) return '';
-  const date = new Date(dateString);
-  return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
+  return formatDateOnly(dateString, { year: 'numeric', month: 'short', day: 'numeric' });
 }
 
 function getDueDateStatus(dateString: string | null): { color: string; label: string } | null {
   if (!dateString) return null;
-  const dueDate = new Date(dateString);
+  const dueDate = parseUTCDate(dateString);
+  if (!dueDate) return null;
   const now = new Date();
   const diffDays = Math.ceil((dueDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
 
@@ -234,6 +235,7 @@ export function ProjectDetailPage() {
   });
 
   const currency = settings?.currency || '$';
+  const timeFormat: TimeFormat = settings?.time_format || 'system';
 
   const updateMutation = useMutation({
     mutationFn: (data: ProjectUpdate) => api.updateProject(projectId, data),
@@ -402,8 +404,7 @@ export function ProjectDetailPage() {
   });
 
   const formatTimelineDate = (timestamp: string) => {
-    const date = new Date(timestamp);
-    return date.toLocaleDateString(undefined, {
+    return formatDateTime(timestamp, timeFormat, {
       month: 'short',
       day: 'numeric',
       hour: '2-digit',

+ 14 - 2
frontend/src/pages/ProjectsPage.tsx

@@ -293,8 +293,20 @@ function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
               )}
               {/* Filament materials/colors */}
               {project.archives && project.archives.length > 0 && (() => {
-                const materials = [...new Set(project.archives.map(a => a.filament_type).filter(Boolean))];
-                const colors = [...new Set(project.archives.map(a => a.filament_color).filter(Boolean))] as string[];
+                // Flatten comma-separated materials and deduplicate
+                const allMaterials = project.archives
+                  .map(a => a.filament_type)
+                  .filter(Boolean)
+                  .flatMap(m => (m as string).split(',').map(s => s.trim()))
+                  .filter(Boolean);
+                const materials = [...new Set(allMaterials)];
+                // Flatten comma-separated colors and deduplicate
+                const allColors = project.archives
+                  .map(a => a.filament_color)
+                  .filter(Boolean)
+                  .flatMap(c => (c as string).split(',').map(s => s.trim()))
+                  .filter(c => c.startsWith('#') || /^[0-9A-Fa-f]{6}$/.test(c));
+                const colors = [...new Set(allColors)];
                 if (materials.length === 0 && colors.length === 0) return null;
                 return (
                   <div className="flex items-center gap-2 mt-1.5">

+ 18 - 4
frontend/src/pages/QueuePage.tsx

@@ -43,6 +43,7 @@ import {
   Hand,
 } from 'lucide-react';
 import { api } from '../api/client';
+import { parseUTCDate, formatDateTime, type TimeFormat } from '../utils/date';
 import type { PrintQueueItem } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
@@ -59,9 +60,10 @@ function formatDuration(seconds: number | null | undefined): string {
   return `${minutes}m`;
 }
 
-function formatRelativeTime(dateString: string | null): string {
+function formatRelativeTime(dateString: string | null, timeFormat: TimeFormat = 'system'): string {
   if (!dateString) return 'ASAP';
-  const date = new Date(dateString);
+  const date = parseUTCDate(dateString);
+  if (!date) return 'ASAP';
   const now = new Date();
   const diff = date.getTime() - now.getTime();
 
@@ -70,7 +72,7 @@ function formatRelativeTime(dateString: string | null): string {
   if (diff < 60000) return 'In less than a minute';
   if (diff < 3600000) return `In ${Math.round(diff / 60000)} min`;
   if (diff < 86400000) return `In ${Math.round(diff / 3600000)} hours`;
-  return date.toLocaleString();
+  return formatDateTime(dateString, timeFormat);
 }
 
 function StatusBadge({ status }: { status: PrintQueueItem['status'] }) {
@@ -103,6 +105,7 @@ function SortableQueueItem({
   onStop,
   onRequeue,
   onStart,
+  timeFormat = 'system',
 }: {
   item: PrintQueueItem;
   position?: number;
@@ -112,6 +115,7 @@ function SortableQueueItem({
   onStop: () => void;
   onRequeue: () => void;
   onStart: () => void;
+  timeFormat?: TimeFormat;
 }) {
   const {
     attributes,
@@ -204,7 +208,7 @@ function SortableQueueItem({
             {isPending && !item.manual_start && (
               <span className="flex items-center gap-1.5">
                 <Clock className="w-3.5 h-3.5" />
-                {formatRelativeTime(item.scheduled_time)}
+                {formatRelativeTime(item.scheduled_time, timeFormat)}
               </span>
             )}
           </div>
@@ -375,6 +379,13 @@ export function QueuePage() {
     useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
   );
 
+  const { data: settings } = useQuery({
+    queryKey: ['settings'],
+    queryFn: api.getSettings,
+  });
+
+  const timeFormat: TimeFormat = settings?.time_format || 'system';
+
   const { data: queue, isLoading } = useQuery({
     queryKey: ['queue', filterPrinter, filterStatus],
     queryFn: () => api.getQueue(filterPrinter || undefined, filterStatus || undefined),
@@ -657,6 +668,7 @@ export function QueuePage() {
                     onStop={() => setConfirmAction({ type: 'stop', item })}
                     onRequeue={() => {}}
                     onStart={() => {}}
+                    timeFormat={timeFormat}
                   />
                 ))}
               </div>
@@ -720,6 +732,7 @@ export function QueuePage() {
                         onStop={() => {}}
                         onRequeue={() => {}}
                         onStart={() => startMutation.mutate(item.id)}
+                        timeFormat={timeFormat}
                       />
                     ))}
                   </div>
@@ -772,6 +785,7 @@ export function QueuePage() {
                     onStop={() => {}}
                     onRequeue={() => setRequeueItem(item)}
                     onStart={() => {}}
+                    timeFormat={timeFormat}
                   />
                 ))}
               </div>

+ 112 - 2
frontend/src/pages/SettingsPage.tsx

@@ -2,6 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, Info, X, Shield, Printer, Cylinder } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
+import { formatDateOnly } from '../utils/date';
 import type { AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus } from '../api/client';
 import { Card, CardContent, CardHeader } from '../components/Card';
 import { Button } from '../components/Button';
@@ -344,7 +345,11 @@ export function SettingsPage() {
       settings.ams_history_retention_days !== localSettings.ams_history_retention_days ||
       settings.date_format !== localSettings.date_format ||
       settings.time_format !== localSettings.time_format ||
-      settings.default_printer_id !== localSettings.default_printer_id;
+      settings.default_printer_id !== localSettings.default_printer_id ||
+      settings.ftp_retry_enabled !== localSettings.ftp_retry_enabled ||
+      settings.ftp_retry_count !== localSettings.ftp_retry_count ||
+      settings.ftp_retry_delay !== localSettings.ftp_retry_delay ||
+      settings.ftp_timeout !== localSettings.ftp_timeout;
 
     if (!hasChanges) {
       return;
@@ -377,6 +382,10 @@ export function SettingsPage() {
         date_format: localSettings.date_format,
         time_format: localSettings.time_format,
         default_printer_id: localSettings.default_printer_id,
+        ftp_retry_enabled: localSettings.ftp_retry_enabled,
+        ftp_retry_count: localSettings.ftp_retry_count,
+        ftp_retry_delay: localSettings.ftp_retry_delay,
+        ftp_timeout: localSettings.ftp_timeout,
       };
       updateMutation.mutate(settingsToSave);
     }, 500);
@@ -1005,6 +1014,103 @@ export function SettingsPage() {
               </div>
             </CardContent>
           </Card>
+
+          {/* FTP Retry Settings */}
+          <Card>
+            <CardHeader>
+              <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+                <RefreshCw className="w-5 h-5 text-blue-400" />
+                FTP Retry
+              </h2>
+            </CardHeader>
+            <CardContent className="space-y-4">
+              <p className="text-sm text-bambu-gray">
+                Retry FTP operations when printer WiFi is unreliable. Applies to 3MF downloads, print uploads, timelapse downloads, and firmware updates.
+              </p>
+
+              <div className="flex items-center justify-between">
+                <div>
+                  <p className="text-white">Enable retry</p>
+                  <p className="text-sm text-bambu-gray">
+                    Automatically retry failed FTP operations
+                  </p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={localSettings.ftp_retry_enabled ?? true}
+                    onChange={(e) => updateSetting('ftp_retry_enabled', e.target.checked)}
+                    className="sr-only peer"
+                  />
+                  <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
+              </div>
+
+              {localSettings.ftp_retry_enabled && (
+                <div className="space-y-4 pt-2 border-t border-bambu-dark-tertiary">
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">
+                      Retry attempts
+                    </label>
+                    <div className="flex items-center gap-2">
+                      <input
+                        type="number"
+                        min="1"
+                        max="10"
+                        value={localSettings.ftp_retry_count ?? 3}
+                        onChange={(e) => updateSetting('ftp_retry_count', Math.min(10, Math.max(1, parseInt(e.target.value) || 3)))}
+                        className="w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                      />
+                      <span className="text-bambu-gray">times</span>
+                    </div>
+                    <p className="text-xs text-bambu-gray mt-1">
+                      Number of retry attempts before giving up (1-10)
+                    </p>
+                  </div>
+
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">
+                      Retry delay
+                    </label>
+                    <div className="flex items-center gap-2">
+                      <input
+                        type="number"
+                        min="1"
+                        max="30"
+                        value={localSettings.ftp_retry_delay ?? 2}
+                        onChange={(e) => updateSetting('ftp_retry_delay', Math.min(30, Math.max(1, parseInt(e.target.value) || 2)))}
+                        className="w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                      />
+                      <span className="text-bambu-gray">seconds</span>
+                    </div>
+                    <p className="text-xs text-bambu-gray mt-1">
+                      Wait time between retries (1-30)
+                    </p>
+                  </div>
+                </div>
+              )}
+
+              <div className="pt-2 border-t border-bambu-dark-tertiary">
+                <label className="block text-sm text-bambu-gray mb-1">
+                  Connection timeout
+                </label>
+                <div className="flex items-center gap-2">
+                  <input
+                    type="number"
+                    min="10"
+                    max="120"
+                    value={localSettings.ftp_timeout ?? 30}
+                    onChange={(e) => updateSetting('ftp_timeout', Math.min(120, Math.max(10, parseInt(e.target.value) || 30)))}
+                    className="w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                  />
+                  <span className="text-bambu-gray">seconds</span>
+                </div>
+                <p className="text-xs text-bambu-gray mt-1">
+                  Socket timeout for slow connections. Increase for A1/A1 Mini printers with weak WiFi (10-120)
+                </p>
+              </div>
+            </CardContent>
+          </Card>
         </div>
 
         {/* Third Column - Updates */}
@@ -1838,7 +1944,7 @@ export function SettingsPage() {
                             <p className="text-white font-medium">{key.name}</p>
                             <p className="text-xs text-bambu-gray">
                               {key.key_prefix}••••••••
-                              {key.last_used && ` · Last used: ${new Date(key.last_used).toLocaleDateString()}`}
+                              {key.last_used && ` · Last used: ${formatDateOnly(key.last_used)}`}
                             </p>
                           </div>
                         </div>
@@ -2174,6 +2280,10 @@ export function SettingsPage() {
                     <CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 shrink-0" />
                     <span>{t('settings.telemetryInfoItem3')}</span>
                   </li>
+                  <li className="flex items-start gap-2 text-bambu-gray">
+                    <CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 shrink-0" />
+                    <span>{t('settings.telemetryInfoItem4')}</span>
+                  </li>
                 </ul>
               </div>
 

+ 187 - 73
frontend/src/pages/StatsPage.tsx

@@ -103,48 +103,83 @@ function QuickStatsWidget({
 
 function SuccessRateWidget({
   stats,
+  printerMap,
+  size = 1,
 }: {
   stats: {
     total_prints: number;
     successful_prints: number;
     failed_prints: number;
+    prints_by_printer: Record<string, number>;
   } | undefined;
+  printerMap: Map<string, string>;
+  size?: 1 | 2 | 4;
 }) {
   const successRate = stats?.total_prints
     ? Math.round((stats.successful_prints / stats.total_prints) * 100)
     : 0;
 
+  // Scale gauge size based on widget size
+  const gaugeSize = size === 1 ? 112 : size === 2 ? 128 : 144;
+  const radius = gaugeSize / 2 - 8;
+  const circumference = radius * 2 * Math.PI;
+
   return (
     <div className="flex items-center gap-6">
-      <div className="relative w-28 h-28">
+      <div className="relative flex-shrink-0" style={{ width: gaugeSize, height: gaugeSize }}>
         <svg className="w-full h-full -rotate-90">
-          <circle cx="56" cy="56" r="48" fill="none" stroke="#3d3d3d" strokeWidth="10" />
           <circle
-            cx="56"
-            cy="56"
-            r="48"
+            cx={gaugeSize / 2}
+            cy={gaugeSize / 2}
+            r={radius}
+            fill="none"
+            stroke="#3d3d3d"
+            strokeWidth="10"
+          />
+          <circle
+            cx={gaugeSize / 2}
+            cy={gaugeSize / 2}
+            r={radius}
             fill="none"
             stroke="#00ae42"
             strokeWidth="10"
             strokeLinecap="round"
-            strokeDasharray={`${successRate * 3.02} 302`}
+            strokeDasharray={`${(successRate / 100) * circumference} ${circumference}`}
           />
         </svg>
         <div className="absolute inset-0 flex items-center justify-center">
-          <span className="text-xl font-bold text-white">{successRate}%</span>
+          <span className={`font-bold text-white ${size >= 2 ? 'text-2xl' : 'text-xl'}`}>{successRate}%</span>
         </div>
       </div>
-      <div className="space-y-2">
-        <div className="flex items-center gap-2">
-          <CheckCircle className="w-4 h-4 text-bambu-green" />
-          <span className="text-sm text-bambu-gray">Successful:</span>
-          <span className="text-sm text-white font-medium">{stats?.successful_prints || 0}</span>
-        </div>
-        <div className="flex items-center gap-2">
-          <XCircle className="w-4 h-4 text-red-400" />
-          <span className="text-sm text-bambu-gray">Failed:</span>
-          <span className="text-sm text-white font-medium">{stats?.failed_prints || 0}</span>
+      <div className="flex-1 min-w-0">
+        <div className="space-y-2">
+          <div className="flex items-center gap-2">
+            <CheckCircle className="w-4 h-4 text-bambu-green flex-shrink-0" />
+            <span className="text-sm text-bambu-gray">Successful:</span>
+            <span className="text-sm text-white font-medium">{stats?.successful_prints || 0}</span>
+          </div>
+          <div className="flex items-center gap-2">
+            <XCircle className="w-4 h-4 text-red-400 flex-shrink-0" />
+            <span className="text-sm text-bambu-gray">Failed:</span>
+            <span className="text-sm text-white font-medium">{stats?.failed_prints || 0}</span>
+          </div>
         </div>
+        {/* Show per-printer breakdown when expanded */}
+        {size >= 2 && stats?.prints_by_printer && Object.keys(stats.prints_by_printer).length > 0 && (
+          <div className="mt-4 pt-4 border-t border-bambu-dark-tertiary">
+            <p className="text-xs text-bambu-gray font-medium mb-2">Prints by Printer</p>
+            <div className={`grid gap-x-6 gap-y-1 ${size === 4 ? 'grid-cols-3' : 'grid-cols-2'}`} style={{ width: 'fit-content' }}>
+              {Object.entries(stats.prints_by_printer).map(([printerId, count]) => (
+                <div key={printerId} className="flex items-center gap-3 text-sm">
+                  <span className="text-bambu-gray truncate max-w-[120px]">
+                    {printerMap.get(printerId) || `Printer ${printerId}`}
+                  </span>
+                  <span className="text-white font-medium">{count}</span>
+                </div>
+              ))}
+            </div>
+          </div>
+        )}
       </div>
     </div>
   );
@@ -153,12 +188,14 @@ function SuccessRateWidget({
 function TimeAccuracyWidget({
   stats,
   printerMap,
+  size = 1,
 }: {
   stats: {
     average_time_accuracy: number | null;
     time_accuracy_by_printer: Record<string, number> | null;
   } | undefined;
   printerMap: Map<string, string>;
+  size?: 1 | 2 | 4;
 }) {
   const accuracy = stats?.average_time_accuracy;
 
@@ -184,38 +221,56 @@ function TimeAccuracyWidget({
   const color = getColor(accuracy);
   const deviation = accuracy - 100;
 
+  // Scale gauge size based on widget size
+  const gaugeSize = size === 1 ? 112 : size === 2 ? 128 : 144;
+  const radius = gaugeSize / 2 - 8;
+  const circumference = radius * 2 * Math.PI;
+
+  // Show more printers when expanded
+  const maxPrinters = size === 1 ? 3 : size === 2 ? 6 : 999;
+  const printerEntries = stats?.time_accuracy_by_printer
+    ? Object.entries(stats.time_accuracy_by_printer).slice(0, maxPrinters)
+    : [];
+
   return (
     <div className="flex items-center gap-6">
-      <div className="relative w-28 h-28">
+      <div className="relative flex-shrink-0" style={{ width: gaugeSize, height: gaugeSize }}>
         <svg className="w-full h-full -rotate-90">
-          <circle cx="56" cy="56" r="48" fill="none" stroke="#3d3d3d" strokeWidth="10" />
           <circle
-            cx="56"
-            cy="56"
-            r="48"
+            cx={gaugeSize / 2}
+            cy={gaugeSize / 2}
+            r={radius}
+            fill="none"
+            stroke="#3d3d3d"
+            strokeWidth="10"
+          />
+          <circle
+            cx={gaugeSize / 2}
+            cy={gaugeSize / 2}
+            r={radius}
             fill="none"
             stroke={color}
             strokeWidth="10"
             strokeLinecap="round"
-            strokeDasharray={`${normalizedForGauge * 3.02} 302`}
+            strokeDasharray={`${(normalizedForGauge / 100) * circumference} ${circumference}`}
           />
         </svg>
         <div className="absolute inset-0 flex flex-col items-center justify-center">
-          <span className="text-xl font-bold text-white">{accuracy.toFixed(0)}%</span>
+          <span className={`font-bold text-white ${size >= 2 ? 'text-2xl' : 'text-xl'}`}>{accuracy.toFixed(0)}%</span>
           <span className={`text-xs ${deviation >= 0 ? 'text-blue-400' : 'text-orange-400'}`}>
             {deviation >= 0 ? '+' : ''}{deviation.toFixed(0)}%
           </span>
         </div>
       </div>
-      <div className="space-y-2 flex-1">
+      <div className="flex-1 min-w-0">
         <div className="flex items-center gap-2 text-xs text-bambu-gray">
-          <Target className="w-3 h-3" />
+          <Target className="w-3 h-3 flex-shrink-0" />
           <span>100% = perfect estimate</span>
         </div>
-        {stats?.time_accuracy_by_printer && Object.keys(stats.time_accuracy_by_printer).length > 0 && (
-          <div className="space-y-1 mt-2">
-            {Object.entries(stats.time_accuracy_by_printer).slice(0, 3).map(([printerId, acc]) => (
-              <div key={printerId} className="flex items-center justify-between text-xs">
+        {printerEntries.length > 0 && (
+          <div className={`mt-2 ${size === 4 ? 'grid grid-cols-3 gap-x-6 gap-y-1' : size === 2 ? 'grid grid-cols-2 gap-x-6 gap-y-1' : 'space-y-1'}`} style={{ width: 'fit-content' }}>
+            {printerEntries.map(([printerId, acc]) => (
+              <div key={printerId} className="flex items-center gap-2 text-xs">
                 <span className="text-bambu-gray truncate max-w-[100px]">
                   {printerMap.get(printerId) || `Printer ${printerId}`}
                 </span>
@@ -236,11 +291,13 @@ function TimeAccuracyWidget({
 
 function FilamentTypesWidget({
   stats,
+  size = 1,
 }: {
   stats: {
     total_prints: number;
     prints_by_filament_type: Record<string, number>;
   } | undefined;
+  size?: 1 | 2 | 4;
 }) {
   if (!stats?.prints_by_filament_type || Object.keys(stats.prints_by_filament_type).length === 0) {
     return <p className="text-bambu-gray text-center py-4">No filament data available</p>;
@@ -251,9 +308,39 @@ function FilamentTypesWidget({
     ([, a], [, b]) => b - a
   );
 
+  // Limit entries based on size
+  const maxEntries = size === 1 ? 5 : size === 2 ? 8 : 999;
+  const displayEntries = sortedEntries.slice(0, maxEntries);
+  const hasMore = sortedEntries.length > maxEntries;
+
+  // Use grid layout when expanded
+  if (size === 4 && displayEntries.length > 4) {
+    return (
+      <div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
+        {displayEntries.map(([type, count]) => {
+          const percentage = Math.round((count / (stats.total_prints || 1)) * 100);
+          return (
+            <div key={type}>
+              <div className="flex justify-between text-sm mb-1">
+                <span className="text-white truncate max-w-[120px]">{type}</span>
+                <span className="text-bambu-gray">{count}</span>
+              </div>
+              <div className="h-2 bg-bambu-dark rounded-full">
+                <div
+                  className="h-full bg-bambu-green rounded-full transition-all"
+                  style={{ width: `${percentage}%` }}
+                />
+              </div>
+            </div>
+          );
+        })}
+      </div>
+    );
+  }
+
   return (
     <div className="space-y-3">
-      {sortedEntries.map(([type, count]) => {
+      {displayEntries.map(([type, count]) => {
         const percentage = Math.round((count / (stats.total_prints || 1)) * 100);
         return (
           <div key={type}>
@@ -270,12 +357,25 @@ function FilamentTypesWidget({
           </div>
         );
       })}
+      {hasMore && (
+        <p className="text-xs text-bambu-gray text-center pt-1">
+          +{sortedEntries.length - maxEntries} more types
+        </p>
+      )}
     </div>
   );
 }
 
-function PrintActivityWidget({ printDates }: { printDates: string[] }) {
-  return <PrintCalendar printDates={printDates} months={4} />;
+function PrintActivityWidget({
+  printDates,
+  size = 2,
+}: {
+  printDates: string[];
+  size?: 1 | 2 | 4;
+}) {
+  // Show more months when widget is larger - cell size auto-calculated
+  const months = size === 1 ? 3 : size === 2 ? 6 : 12;
+  return <PrintCalendar printDates={printDates} months={months} />;
 }
 
 function PrintsByPrinterWidget({
@@ -321,7 +421,7 @@ function FilamentTrendsWidget({
   return <FilamentTrends archives={archives} currency={currency} />;
 }
 
-function FailureAnalysisWidget() {
+function FailureAnalysisWidget({ size = 1 }: { size?: 1 | 2 | 4 }) {
   const { data: analysis, isLoading } = useQuery({
     queryKey: ['failureAnalysis'],
     queryFn: () => api.getFailureAnalysis({ days: 30 }),
@@ -339,50 +439,63 @@ function FailureAnalysisWidget() {
     return <p className="text-bambu-gray text-center py-4">No print data in the last 30 days</p>;
   }
 
-  const topReasons = Object.entries(analysis.failures_by_reason)
-    .sort(([, a], [, b]) => b - a)
-    .slice(0, 5);
+  // Show more reasons when expanded
+  const maxReasons = size === 1 ? 5 : size === 2 ? 8 : 999;
+  const allReasons = Object.entries(analysis.failures_by_reason).sort(([, a], [, b]) => b - a);
+  const topReasons = allReasons.slice(0, maxReasons);
+  const hasMore = allReasons.length > maxReasons;
 
   return (
-    <div className="space-y-4">
+    <div className={`${size >= 2 ? 'flex gap-8' : 'space-y-4'}`}>
       {/* Summary */}
-      <div className="flex items-center gap-4">
-        <div className="flex items-center gap-2">
-          <AlertTriangle className={`w-5 h-5 ${analysis.failure_rate > 20 ? 'text-red-400' : analysis.failure_rate > 10 ? 'text-yellow-400' : 'text-bambu-green'}`} />
-          <span className="text-2xl font-bold text-white">{analysis.failure_rate.toFixed(1)}%</span>
-          <span className="text-sm text-bambu-gray">failure rate</span>
+      <div className={size >= 2 ? 'flex-shrink-0' : ''}>
+        <div className="flex items-center gap-4">
+          <div className="flex items-center gap-2">
+            <AlertTriangle className={`w-5 h-5 ${analysis.failure_rate > 20 ? 'text-red-400' : analysis.failure_rate > 10 ? 'text-yellow-400' : 'text-bambu-green'}`} />
+            <span className={`font-bold text-white ${size >= 2 ? 'text-3xl' : 'text-2xl'}`}>{analysis.failure_rate.toFixed(1)}%</span>
+          </div>
         </div>
-        <div className="text-sm text-bambu-gray">
+        <div className="text-sm text-bambu-gray mt-1">
           {analysis.failed_prints} / {analysis.total_prints} prints failed
         </div>
+        {/* Trend indicator */}
+        {analysis.trend && analysis.trend.length >= 2 && (
+          <div className={`${size >= 2 ? 'mt-4' : 'mt-2 pt-2 border-t border-bambu-dark-tertiary'}`}>
+            <div className="flex items-center gap-2 text-sm">
+              <TrendingDown className={`w-4 h-4 ${
+                analysis.trend[analysis.trend.length - 1].failure_rate < analysis.trend[analysis.trend.length - 2].failure_rate
+                  ? 'text-bambu-green'
+                  : 'text-red-400'
+              }`} />
+              <span className="text-bambu-gray">
+                Last week: {analysis.trend[analysis.trend.length - 1].failure_rate.toFixed(1)}%
+              </span>
+            </div>
+          </div>
+        )}
       </div>
 
-      {/* Top Failure Reasons */}
+      {/* Failure Reasons */}
       {topReasons.length > 0 && (
-        <div className="space-y-2">
-          <p className="text-xs text-bambu-gray font-medium">Top Failure Reasons</p>
-          {topReasons.map(([reason, count]) => (
-            <div key={reason} className="flex items-center justify-between text-sm">
-              <span className="text-white truncate max-w-[200px]">{reason || 'Unknown'}</span>
-              <span className="text-bambu-gray">{count}</span>
-            </div>
-          ))}
-        </div>
-      )}
-
-      {/* Trend indicator */}
-      {analysis.trend && analysis.trend.length >= 2 && (
-        <div className="pt-2 border-t border-bambu-dark-tertiary">
-          <div className="flex items-center gap-2 text-sm">
-            <TrendingDown className={`w-4 h-4 ${
-              analysis.trend[analysis.trend.length - 1].failure_rate < analysis.trend[analysis.trend.length - 2].failure_rate
-                ? 'text-bambu-green'
-                : 'text-red-400'
-            }`} />
-            <span className="text-bambu-gray">
-              Last week: {analysis.trend[analysis.trend.length - 1].failure_rate.toFixed(1)}%
-            </span>
+        <div className={`flex-1 ${size >= 2 ? 'border-l border-bambu-dark-tertiary pl-8' : 'pt-2'}`}>
+          <p className="text-xs text-bambu-gray font-medium mb-2">
+            {size >= 2 ? 'Failure Reasons' : 'Top Failure Reasons'}
+          </p>
+          <div className={`${size === 4 ? 'grid grid-cols-2 gap-x-6 gap-y-1' : 'space-y-1'}`}>
+            {topReasons.map(([reason, count]) => (
+              <div key={reason} className="flex items-center justify-between text-sm">
+                <span className={`text-white truncate ${size === 4 ? 'max-w-[200px]' : 'max-w-[160px]'}`}>
+                  {reason || 'Unknown'}
+                </span>
+                <span className="text-bambu-gray ml-2">{count}</span>
+              </div>
+            ))}
           </div>
+          {hasMore && (
+            <p className="text-xs text-bambu-gray mt-2">
+              +{allReasons.length - maxReasons} more reasons
+            </p>
+          )}
         </div>
       )}
     </div>
@@ -473,6 +586,7 @@ export function StatsPage() {
 
   // Define dashboard widgets
   // Sizes: 1 = quarter (1/4), 2 = half (1/2), 4 = full width
+  // Widgets can use render functions to receive the current size for responsive content
   const widgets: DashboardWidget[] = [
     {
       id: 'quick-stats',
@@ -483,31 +597,31 @@ export function StatsPage() {
     {
       id: 'success-rate',
       title: 'Success Rate',
-      component: <SuccessRateWidget stats={stats} />,
+      component: (size) => <SuccessRateWidget stats={stats} printerMap={printerMap} size={size} />,
       defaultSize: 1,
     },
     {
       id: 'time-accuracy',
       title: 'Time Accuracy',
-      component: <TimeAccuracyWidget stats={stats} printerMap={printerMap} />,
+      component: (size) => <TimeAccuracyWidget stats={stats} printerMap={printerMap} size={size} />,
       defaultSize: 1,
     },
     {
       id: 'filament-types',
       title: 'Filament Types',
-      component: <FilamentTypesWidget stats={stats} />,
+      component: (size) => <FilamentTypesWidget stats={stats} size={size} />,
       defaultSize: 1,
     },
     {
       id: 'failure-analysis',
       title: 'Failure Analysis (30 days)',
-      component: <FailureAnalysisWidget />,
+      component: (size) => <FailureAnalysisWidget size={size} />,
       defaultSize: 1,
     },
     {
       id: 'print-activity',
       title: 'Print Activity',
-      component: <PrintActivityWidget printDates={printDates} />,
+      component: (size) => <PrintActivityWidget printDates={printDates} size={size} />,
       defaultSize: 2,
     },
     {

+ 176 - 3
frontend/src/pages/SystemInfoPage.tsx

@@ -1,4 +1,5 @@
-import { useQuery } from '@tanstack/react-query';
+import { useState } from 'react';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import {
   Server,
@@ -16,9 +17,13 @@ import {
   Plug,
   FolderKanban,
   Palette,
+  Bug,
+  Download,
+  Headphones,
 } from 'lucide-react';
-import { api } from '../api/client';
+import { api, supportApi } from '../api/client';
 import { Card } from '../components/Card';
+import { formatDateTime, type TimeFormat } from '../utils/date';
 
 function StatCard({
   icon: Icon,
@@ -80,6 +85,10 @@ function Section({
 
 export function SystemInfoPage() {
   const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const [bundleError, setBundleError] = useState<string | null>(null);
+  const [bundleDownloading, setBundleDownloading] = useState(false);
+  const [debugToggling, setDebugToggling] = useState(false);
 
   const { data: systemInfo, isLoading, refetch, isFetching } = useQuery({
     queryKey: ['systemInfo'],
@@ -87,6 +96,45 @@ export function SystemInfoPage() {
     refetchInterval: 30000, // Auto-refresh every 30 seconds
   });
 
+  const { data: debugLoggingState } = useQuery({
+    queryKey: ['debugLogging'],
+    queryFn: supportApi.getDebugLoggingState,
+    staleTime: 10 * 1000, // 10 seconds
+    refetchInterval: 10 * 1000,
+  });
+
+  const { data: settings } = useQuery({
+    queryKey: ['settings'],
+    queryFn: api.getSettings,
+  });
+
+  const timeFormat: TimeFormat = settings?.time_format || 'system';
+
+  const handleToggleDebugLogging = async () => {
+    setDebugToggling(true);
+    try {
+      const newState = await supportApi.setDebugLogging(!debugLoggingState?.enabled);
+      // Immediately update the cache with the new state (includes fresh enabled_at timestamp)
+      queryClient.setQueryData(['debugLogging'], newState);
+    } catch (err) {
+      console.error('Failed to toggle debug logging:', err);
+    } finally {
+      setDebugToggling(false);
+    }
+  };
+
+  const handleDownloadBundle = async () => {
+    setBundleError(null);
+    setBundleDownloading(true);
+    try {
+      await supportApi.downloadSupportBundle();
+    } catch (err) {
+      setBundleError(err instanceof Error ? err.message : 'Failed to download support bundle');
+    } finally {
+      setBundleDownloading(false);
+    }
+  };
+
   if (isLoading) {
     return (
       <div className="flex items-center justify-center h-64">
@@ -158,6 +206,131 @@ export function SystemInfoPage() {
         </div>
       </Section>
 
+      {/* Support & Troubleshooting */}
+      <Section title={t('support.title', 'Support & Troubleshooting')} icon={Headphones}>
+        <div className="space-y-4">
+          <p className="text-sm text-bambu-gray">
+            {t('support.description', 'Enable debug logging to capture detailed information, then download a support bundle to share when reporting issues.')}
+          </p>
+
+          {/* Debug Logging Toggle */}
+          <div className="flex items-center justify-between p-4 bg-bambu-dark rounded-lg">
+            <div className="flex items-center gap-3">
+              <div className={`p-2 rounded-lg ${debugLoggingState?.enabled ? 'bg-amber-500/20 text-amber-500' : 'bg-bambu-dark-tertiary text-bambu-gray'}`}>
+                <Bug className="w-5 h-5" />
+              </div>
+              <div>
+                <p className="font-medium text-white">{t('support.debugLogging', 'Debug Logging')}</p>
+                <p className="text-sm text-bambu-gray">
+                  {debugLoggingState?.enabled
+                    ? t('support.debugLoggingEnabled', 'Capturing detailed logs')
+                    : t('support.debugLoggingDisabled', 'Normal logging level')}
+                  {debugLoggingState?.enabled && debugLoggingState.duration_seconds !== null && (
+                    <span className="text-amber-400 ml-2">
+                      ({Math.floor(debugLoggingState.duration_seconds / 60)}m {debugLoggingState.duration_seconds % 60}s)
+                    </span>
+                  )}
+                </p>
+              </div>
+            </div>
+            <button
+              onClick={handleToggleDebugLogging}
+              disabled={debugToggling}
+              className={`px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2 ${
+                debugLoggingState?.enabled
+                  ? 'bg-amber-500/20 text-amber-400 hover:bg-amber-500/30'
+                  : 'bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30'
+              } disabled:opacity-50`}
+            >
+              {debugToggling && <Loader2 className="w-4 h-4 animate-spin" />}
+              {debugLoggingState?.enabled
+                ? t('support.disableDebug', 'Disable')
+                : t('support.enableDebug', 'Enable')}
+            </button>
+          </div>
+
+          {/* Support Bundle Download */}
+          <div className="flex items-center justify-between p-4 bg-bambu-dark rounded-lg">
+            <div className="flex items-center gap-3">
+              <div className="p-2 rounded-lg bg-bambu-dark-tertiary text-bambu-green">
+                <Download className="w-5 h-5" />
+              </div>
+              <div>
+                <p className="font-medium text-white">{t('support.supportBundle', 'Support Bundle')}</p>
+                <p className="text-sm text-bambu-gray">
+                  {t('support.supportBundleDescription', 'Download system info and logs as a ZIP file')}
+                </p>
+              </div>
+            </div>
+            <button
+              onClick={handleDownloadBundle}
+              disabled={bundleDownloading || !debugLoggingState?.enabled}
+              className="px-4 py-2 rounded-lg font-medium bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30 transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
+              title={!debugLoggingState?.enabled ? t('support.enableDebugFirst', 'Enable debug logging first') : undefined}
+            >
+              {bundleDownloading && <Loader2 className="w-4 h-4 animate-spin" />}
+              {t('common.download', 'Download')}
+            </button>
+          </div>
+
+          {/* Error message */}
+          {bundleError && (
+            <div className="p-3 bg-red-500/20 border border-red-500/30 rounded-lg text-red-400 text-sm">
+              {bundleError}
+            </div>
+          )}
+
+          {/* Instructions */}
+          {!debugLoggingState?.enabled && (
+            <div className="p-4 bg-bambu-dark-tertiary/50 rounded-lg">
+              <p className="text-sm text-bambu-gray">
+                <span className="text-amber-400 font-medium">{t('support.instructions', 'To report an issue:')}</span>
+                <br />
+                1. {t('support.step1', 'Enable debug logging')}
+                <br />
+                2. {t('support.step2', 'Reproduce the issue')}
+                <br />
+                3. {t('support.step3', 'Download the support bundle')}
+                <br />
+                4. {t('support.step4', 'Attach the ZIP file to your issue report')}
+              </p>
+            </div>
+          )}
+
+          {/* Privacy Info */}
+          <div className="p-4 bg-bambu-dark rounded-lg space-y-3">
+            <p className="text-sm font-medium text-white">{t('support.privacyTitle', 'What\'s in the support bundle?')}</p>
+            <div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
+              <div>
+                <p className="text-bambu-green font-medium mb-1">{t('support.collected', 'Collected:')}</p>
+                <ul className="text-bambu-gray space-y-0.5">
+                  <li>• {t('support.collectItem1', 'App version and debug mode')}</li>
+                  <li>• {t('support.collectItem2', 'OS, architecture, Python version')}</li>
+                  <li>• {t('support.collectItem3', 'Database statistics (counts only)')}</li>
+                  <li>• {t('support.collectItem4', 'Printer models and nozzle counts')}</li>
+                  <li>• {t('support.collectItem5', 'Non-sensitive settings (themes, formats)')}</li>
+                  <li>• {t('support.collectItem6', 'Debug logs (sanitized)')}</li>
+                </ul>
+              </div>
+              <div>
+                <p className="text-red-400 font-medium mb-1">{t('support.notCollected', 'NOT collected:')}</p>
+                <ul className="text-bambu-gray space-y-0.5">
+                  <li>• {t('support.notItem1', 'Printer names, IPs, serial numbers')}</li>
+                  <li>• {t('support.notItem2', 'Access codes and passwords')}</li>
+                  <li>• {t('support.notItem3', 'Email addresses')}</li>
+                  <li>• {t('support.notItem4', 'API keys and tokens')}</li>
+                  <li>• {t('support.notItem5', 'Webhook URLs')}</li>
+                  <li>• {t('support.notItem6', 'Your hostname or username')}</li>
+                </ul>
+              </div>
+            </div>
+            <p className="text-xs text-bambu-gray/70">
+              {t('support.privacyNote', 'IP addresses in logs are replaced with [IP] and email addresses with [EMAIL].')}
+            </p>
+          </div>
+        </div>
+      </Section>
+
       {/* Database Stats */}
       <Section title={t('system.database', 'Database')} icon={Database}>
         <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
@@ -360,7 +533,7 @@ export function SystemInfoPage() {
           <StatCard
             icon={Clock}
             label={t('system.bootTime', 'Boot Time')}
-            value={new Date(systemInfo.system.boot_time).toLocaleString()}
+            value={formatDateTime(systemInfo.system.boot_time, timeFormat)}
           />
         </div>
       </Section>

+ 107 - 0
frontend/src/utils/colors.ts

@@ -0,0 +1,107 @@
+// Bambu Lab filament hex color to name mapping (from bambu-color-names.csv)
+const BAMBU_HEX_COLORS: Record<string, string> = {
+  '000000': 'Black', '001489': 'Blue', '002e96': 'Blue', '0047bb': 'Blue', '00482b': 'Pine Green',
+  '004ea8': 'Blue', '0056b8': 'Cobalt Blue', '0069b1': 'Lake Blue', '0072ce': 'Blue', '0078bf': 'Marine Blue',
+  '0085ad': 'Light Blue', '0086d6': 'Cyan', '008bda': 'Blue', '009639': 'Green', '009bd8': 'Cyan',
+  '009fa1': 'Teal', '00a6a0': 'Green', '00ae42': 'Bambu Green', '00b1b7': 'Turquoise', '00bb31': 'Green',
+  '018814': 'Candy Green', '042f56': 'Dark Blue', '0a2989': 'Blue', '0a2ca5': 'Blue', '0c2340': 'Navy Blue',
+  '0c3b95': 'Blue', '101820': 'Black', '147bd1': 'Blue', '164b35': 'Green', '16b08e': 'Malachite Green',
+  '1d7c6a': 'Oxide Green Metallic', '1f79e5': 'Lake Blue', '2140b4': 'Blue', '25282a': 'Black', '2842ad': 'Royal Blue',
+  '2d2b28': 'Onyx Black Sparkle', '324585': 'Indigo Blue', '353533': 'Gray', '39541a': 'Forest Green',
+  '39699e': 'Cobalt Blue Metallic', '3b665e': 'Green', '3f5443': 'Alpine Green Sparkle', '3f8e43': 'Mistletoe Green',
+  '424379': 'Nebulae', '43403d': 'Iron Gray Metallic', '482960': 'Indigo Purple', '483d8b': 'Royal Purple Sparkle',
+  '489fdf': 'Azure', '4c241c': 'Rosewood', '4ce4a0': 'Green', '4d3324': 'Dark Chocolate', '4d5054': 'Lava Gray',
+  '4dafda': 'Cyan', '4f3f24': 'Black Walnut', '515151': 'Dark Gray', '515a6c': 'Gray', '545454': 'Dark Gray',
+  '565656': 'Titan Gray', '56b7e6': 'Sky Blue', '583061': 'Violet Purple', '5898dd': 'Blue', '594177': 'Purple',
+  '5b492f': 'Brown', '5b6579': 'Blue Gray', '5c9748': 'Matcha Green', '5e43b7': 'Purple', '5e4b3c': 'Copper',
+  '5f6367': 'Titan Gray', '61b0ff': 'Translucent Light Blue', '61bf36': 'Green', '61c680': 'Grass Green',
+  '6667ab': 'Lavender Blue', '684a43': 'Brown', '686865': 'Black', '68724d': 'Dark Green', '688197': 'Blue Gray',
+  '69398e': 'Iris Purple', '6e88bc': 'Jeans Blue', '6ee53c': 'Lime Green', '6f5034': 'Cocoa Brown', '7248bd': 'Lavender',
+  '748c45': 'Translucent Olive', '757575': 'Nardo Gray', '75aed8': 'Blue', '77edd7': 'Translucent Teal', '789d4a': 'Olive',
+  '792b36': 'Crimson Red Sparkle', '7ac0e9': 'Glow Blue', '7ae1bf': 'Mint', '7cd82b': 'Lime Green', '7d6556': 'Dark Brown',
+  '8344b0': 'Purple', '847d48': 'Bronze', '854ce4': 'Purple', '8671cb': 'Purple', '875718': 'Peanut Brown',
+  '87909a': 'Silver', '898d8d': 'Gray', '8a949e': 'Gray', '8e8e8e': 'Translucent Gray', '8e9089': 'Gray',
+  '90ff1a': 'Neon Green', '918669': 'Classic Birch', '939393': 'Gray', '950051': 'Plum', '951e23': 'Burgundy Red',
+  '959698': 'Silver', '96d8af': 'Light Jade', '96dcb9': 'Mint', '995f11': 'Clay Brown', '999d9d': 'Gray',
+  '9b9ea0': 'Ash Gray', '9d2235': 'Maroon Red', '9d432c': 'Brown', '9e007e': 'Purple', '9ea2a2': 'Gray',
+  '9f332a': 'Brick Red', 'a1ffac': 'Glow Green', 'a3d8e1': 'Ice Blue', 'a6a9aa': 'Silver', 'a8a8aa': 'Gray',
+  'a8c6ee': 'Baby Blue', 'aa6443': 'Copper Brown Metallic', 'ad4e38': 'Red Granite', 'adb1b2': 'Gray',
+  'ae835b': 'Caramel', 'ae96d4': 'Lilac Purple', 'af1685': 'Purple', 'afb1ae': 'Gray', 'b15533': 'Terracotta',
+  'b28b33': 'Gold', 'b39b84': 'Iridium Gold Metallic', 'b50011': 'Red', 'b8acd6': 'Lavender', 'b8cde9': 'Ice Blue',
+  'ba9594': 'Rose Gold', 'bb3d43': 'Dark Red', 'bc0900': 'Red', 'becf00': 'Bright Green', 'c0df16': 'Green',
+  'c12e1f': 'Red', 'c2e189': 'Apple Green', 'c3e2d6': 'Light Cyan', 'c5ed48': 'Lime', 'c6001a': 'Red',
+  'c6c6c6': 'Gray', 'c8102e': 'Red', 'c8c8c8': 'Silver', 'c98935': 'Ochre Yellow', 'c9a381': 'Translucent Brown',
+  'cbc6b8': 'Bone White', 'cdceca': 'Gray', 'cea629': 'Classic Gold Sparkle', 'd02727': 'Candy Red',
+  'd1d3d5': 'Light Gray', 'd32941': 'Red', 'd3b7a7': 'Latte Brown', 'd6001c': 'Red', 'd6abff': 'Translucent Purple',
+  'd6cca3': 'White Oak', 'dc3a27': 'Orange', 'dd3c22': 'Vermilion Red', 'de4343': 'Scarlet Red', 'dfd1a7': 'Beige',
+  'e02928': 'Red', 'e4bd68': 'Gold', 'e5b03d': 'Gold', 'e83100': 'Red', 'e8afcf': 'Sakura Pink', 'e8dbb7': 'Desert Tan',
+  'eaeae4': 'White', 'eaeceb': 'Silver', 'ec008c': 'Magenta', 'ed0000': 'Red', 'eeb1c1': 'Pink', 'efe255': 'Yellow',
+  'f0f1a8': 'Clear', 'f17b8f': 'Glow Pink', 'f3cfb2': 'Champagne', 'f3e600': 'Yellow', 'f48438': 'Orange',
+  'f4a925': 'Gold', 'f4d53f': 'Yellow', 'f4ee2a': 'Yellow', 'f5547c': 'Hot Pink', 'f55a74': 'Pink',
+  'f5b6cd': 'Cherry Pink', 'f5dbab': 'Mellow Yellow', 'f5f1dd': 'White', 'f68b1b': 'Neon Orange', 'f74e02': 'Orange',
+  'f75403': 'Orange', 'f7ada6': 'Pink', 'f7d959': 'Lemon Yellow', 'f7e6de': 'Beige', 'f7f3f0': 'White Marble',
+  'f8ff80': 'Glow Yellow', 'f99963': 'Mandarin Orange', 'f9c1bd': 'Translucent Pink', 'f9dfb9': 'Cream',
+  'f9ef41': 'Yellow', 'f9f7f2': 'Nature', 'f9f7f4': 'White', 'fce300': 'Yellow', 'fce900': 'Yellow',
+  'fec600': 'Sunflower Yellow', 'fedb00': 'Yellow', 'ff4800': 'Orange', 'ff671f': 'Orange', 'ff6a13': 'Orange',
+  'ff7f41': 'Orange', 'ff9016': 'Pumpkin Orange', 'ff911a': 'Translucent Orange', 'ff9d5b': 'Glow Orange',
+  'ffb549': 'Sunflower Yellow', 'ffc72c': 'Tangerine Yellow', 'ffce00': 'Yellow', 'ffd00b': 'Yellow',
+  'ffe133': 'Yellow', 'fffaf2': 'White', 'ffffff': 'White',
+};
+
+/**
+ * Convert hex color to basic color name using HSL analysis.
+ * Used as fallback when hex is not in Bambu database.
+ */
+export function hexToColorName(hex: string | null | undefined): string {
+  if (!hex || hex.length < 6) return 'Unknown';
+  const cleanHex = hex.replace('#', '');
+  const r = parseInt(cleanHex.substring(0, 2), 16);
+  const g = parseInt(cleanHex.substring(2, 4), 16);
+  const b = parseInt(cleanHex.substring(4, 6), 16);
+
+  const max = Math.max(r, g, b) / 255;
+  const min = Math.min(r, g, b) / 255;
+  const l = (max + min) / 2;
+
+  let h = 0;
+  let s = 0;
+
+  if (max !== min) {
+    const d = max - min;
+    s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
+    const rNorm = r / 255, gNorm = g / 255, bNorm = b / 255;
+    if (max === rNorm) h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6;
+    else if (max === gNorm) h = ((bNorm - rNorm) / d + 2) / 6;
+    else h = ((rNorm - gNorm) / d + 4) / 6;
+  }
+  h = h * 360;
+
+  if (l < 0.15) return 'Black';
+  if (l > 0.85) return 'White';
+  if (s < 0.15) {
+    if (l < 0.4) return 'Dark Gray';
+    if (l > 0.6) return 'Light Gray';
+    return 'Gray';
+  }
+  if (h < 15 || h >= 345) return 'Red';
+  if (h < 45) return 'Orange';
+  if (h < 70) return 'Yellow';
+  if (h < 150) return 'Green';
+  if (h < 200) return 'Cyan';
+  if (h < 260) return 'Blue';
+  if (h < 290) return 'Purple';
+  if (h < 345) return 'Pink';
+  return 'Unknown';
+}
+
+/**
+ * Get color name from hex color.
+ * First tries Bambu Lab color database lookup, then falls back to HSL-based name.
+ */
+export function getColorName(hexColor: string): string {
+  const hex = hexColor.replace('#', '').toLowerCase().substring(0, 6);
+  if (BAMBU_HEX_COLORS[hex]) {
+    return BAMBU_HEX_COLORS[hex];
+  }
+  return hexToColorName(hexColor);
+}

+ 143 - 0
frontend/src/utils/date.ts

@@ -0,0 +1,143 @@
+/**
+ * Date utilities for handling UTC timestamps from the backend.
+ *
+ * The backend stores all timestamps in UTC without timezone indicators.
+ * These utilities ensure dates are properly interpreted as UTC and
+ * displayed in the user's local timezone.
+ */
+
+export type TimeFormat = 'system' | '12h' | '24h';
+
+/**
+ * Apply time format setting to Intl.DateTimeFormatOptions.
+ * This modifies the options object in place and returns it.
+ */
+export function applyTimeFormat(
+  options: Intl.DateTimeFormatOptions,
+  timeFormat: TimeFormat = 'system'
+): Intl.DateTimeFormatOptions {
+  if (timeFormat === '12h') {
+    options.hour12 = true;
+  } else if (timeFormat === '24h') {
+    options.hour12 = false;
+  }
+  // 'system' leaves hour12 undefined, letting the browser decide
+  return options;
+}
+
+/**
+ * Parse a date string from the backend as UTC.
+ * Handles ISO 8601 strings with or without timezone indicators.
+ *
+ * @param dateStr - Date string from backend (e.g., "2026-01-09T12:03:36.288768")
+ * @returns Date object in local timezone
+ */
+export function parseUTCDate(dateStr: string | null | undefined): Date | null {
+  if (!dateStr) return null;
+
+  // If the string already has a timezone indicator, parse as-is
+  if (dateStr.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(dateStr)) {
+    return new Date(dateStr);
+  }
+
+  // Otherwise, append 'Z' to interpret as UTC
+  return new Date(dateStr + 'Z');
+}
+
+/**
+ * Format a UTC date string to a localized date/time string.
+ *
+ * @param dateStr - Date string from backend
+ * @param options - Intl.DateTimeFormat options (defaults to showing date and time)
+ * @returns Formatted date string in user's locale and timezone
+ */
+export function formatDate(
+  dateStr: string | null | undefined,
+  options?: Intl.DateTimeFormatOptions
+): string {
+  const date = parseUTCDate(dateStr);
+  if (!date) return '';
+
+  const defaultOptions: Intl.DateTimeFormatOptions = {
+    year: 'numeric',
+    month: 'short',
+    day: 'numeric',
+    hour: '2-digit',
+    minute: '2-digit',
+  };
+
+  return date.toLocaleString(undefined, options ?? defaultOptions);
+}
+
+/**
+ * Format a UTC date string to a localized date-only string.
+ *
+ * @param dateStr - Date string from backend
+ * @param options - Intl.DateTimeFormat options
+ * @returns Formatted date string in user's locale and timezone
+ */
+export function formatDateOnly(
+  dateStr: string | null | undefined,
+  options?: Intl.DateTimeFormatOptions
+): string {
+  const date = parseUTCDate(dateStr);
+  if (!date) return '';
+
+  const defaultOptions: Intl.DateTimeFormatOptions = {
+    year: 'numeric',
+    month: 'short',
+    day: 'numeric',
+  };
+
+  return date.toLocaleDateString(undefined, options ?? defaultOptions);
+}
+
+/**
+ * Format a UTC date string to a localized date/time string with time format support.
+ *
+ * @param dateStr - Date string from backend
+ * @param timeFormat - Time format setting ('system', '12h', '24h')
+ * @param options - Intl.DateTimeFormat options (defaults to showing date and time)
+ * @returns Formatted date string in user's locale and timezone
+ */
+export function formatDateTime(
+  dateStr: string | null | undefined,
+  timeFormat: TimeFormat = 'system',
+  options?: Intl.DateTimeFormatOptions
+): string {
+  const date = parseUTCDate(dateStr);
+  if (!date) return '';
+
+  const defaultOptions: Intl.DateTimeFormatOptions = {
+    year: 'numeric',
+    month: 'short',
+    day: 'numeric',
+    hour: '2-digit',
+    minute: '2-digit',
+  };
+
+  const finalOptions = applyTimeFormat(options ?? defaultOptions, timeFormat);
+  return date.toLocaleString(undefined, finalOptions);
+}
+
+/**
+ * Format a Date object to a localized time string with time format support.
+ *
+ * @param date - Date object
+ * @param timeFormat - Time format setting ('system', '12h', '24h')
+ * @param options - Additional Intl.DateTimeFormat options
+ * @returns Formatted time string
+ */
+export function formatTimeOnly(
+  date: Date,
+  timeFormat: TimeFormat = 'system',
+  options?: Intl.DateTimeFormatOptions
+): string {
+  const defaultOptions: Intl.DateTimeFormatOptions = {
+    hour: '2-digit',
+    minute: '2-digit',
+  };
+
+  const finalOptions = applyTimeFormat({ ...defaultOptions, ...options }, timeFormat);
+  return date.toLocaleTimeString([], finalOptions);
+}

+ 3 - 0
requirements.txt

@@ -31,6 +31,9 @@ pywebpush>=2.0.0
 python-multipart>=0.0.6
 aiofiles>=23.0.0
 
+# QR Code generation
+qrcode[pil]>=7.4.0
+
 # System monitoring
 psutil>=6.0.0
 

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BaxJ1N11.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DSlUhPr3.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-Ds1sabci.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-rSeEpV8h.js


+ 2 - 2
static/index.html

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

+ 2 - 2
static/sw.js

@@ -1,6 +1,6 @@
 // Bambuddy Service Worker
-const CACHE_NAME = 'bambuddy-v4';
-const STATIC_CACHE = 'bambuddy-static-v4';
+const CACHE_NAME = 'bambuddy-v21';
+const STATIC_CACHE = 'bambuddy-static-v21';
 
 // Static assets to cache on install
 const STATIC_ASSETS = [

+ 5 - 0
update_website_wiki.sh

@@ -1,5 +1,10 @@
 #!/bin/bash
 
+cd ../bambuddy-telemetry
+git add .
+git commit -m "Updated telemetry"
+git push
+
 cd ../bambuddy-website
 git add .
 git commit -m "Updated website"

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