Browse Source

Merge pull request #7 from maziggy/0.1.4

  New Features in 0.1.4:

  1. Multi-language Support (i18n)
    - English and German translations
    - Browser auto-detect with manual override in Settings
    - Notification language can be set separately
    - Extensible structure for adding more languages
  2. Auto App Update
    - Automatic update checking from GitHub releases
    - One-click update with progress feedback
    - Update notifications in the UI
  3. Maintenance Module
    - Track printer maintenance tasks (nozzle changes, lubrication, etc.)
    - Configurable maintenance intervals (print hours or calendar days)
    - Maintenance due notifications via push providers
    - Maintenance history tracking
  4. Enhanced Spoolman Integration
    - Auto-create unknown Bambu Lab spools in Spoolman
    - Improved spool tracking with tray UUID matching
  5. Source 3MF Upload
    - Upload original slicer-saved 3MF files to archive cards
    - Keep both printer 3MF and source 3MF together

  Bug Fixes in 0.1.4:

  1. K-Profiles Loading Fix
    - Fixed threading issue where K-profiles weren't being fetched from printer
    - Increased timeout for slower-responding printers
    - Fixed frontend auto-load on page navigation
MartinNYHC 5 months ago
parent
commit
45d0eab222
50 changed files with 5687 additions and 201 deletions
  1. 76 0
      README.md
  2. 227 0
      backend/app/api/routes/archives.py
  3. 520 0
      backend/app/api/routes/maintenance.py
  4. 2 0
      backend/app/api/routes/notifications.py
  5. 34 1
      backend/app/api/routes/settings.py
  6. 351 0
      backend/app/api/routes/spoolman.py
  7. 276 0
      backend/app/api/routes/updates.py
  8. 4 0
      backend/app/core/config.py
  9. 19 1
      backend/app/core/database.py
  10. 133 0
      backend/app/i18n/__init__.py
  11. 227 3
      backend/app/main.py
  12. 11 1
      backend/app/models/__init__.py
  13. 1 0
      backend/app/models/archive.py
  14. 78 0
      backend/app/models/maintenance.py
  15. 1 0
      backend/app/models/notification.py
  16. 6 1
      backend/app/models/printer.py
  17. 1 0
      backend/app/schemas/archive.py
  18. 118 0
      backend/app/schemas/maintenance.py
  19. 2 0
      backend/app/schemas/notification.py
  20. 2 0
      backend/app/schemas/printer.py
  21. 16 0
      backend/app/schemas/settings.py
  22. 71 4
      backend/app/services/bambu_mqtt.py
  23. 96 35
      backend/app/services/notification_service.py
  24. 12 0
      backend/app/services/printer_manager.py
  25. 712 0
      backend/app/services/spoolman.py
  26. BIN
      docs/screenshots/maintenance.png
  27. 160 1
      frontend/package-lock.json
  28. 4 0
      frontend/package.json
  29. 2 0
      frontend/src/App.tsx
  30. 198 0
      frontend/src/api/client.ts
  31. 5 9
      frontend/src/components/AddNotificationModal.tsx
  32. 27 7
      frontend/src/components/KProfilesView.tsx
  33. 6 4
      frontend/src/components/KeyboardShortcutsModal.tsx
  34. 66 19
      frontend/src/components/Layout.tsx
  35. 55 90
      frontend/src/components/NotificationProviderCard.tsx
  36. 378 0
      frontend/src/components/SpoolmanSettings.tsx
  37. 38 0
      frontend/src/components/Toggle.tsx
  38. 46 0
      frontend/src/i18n/index.ts
  39. 358 0
      frontend/src/i18n/locales/de.ts
  40. 358 0
      frontend/src/i18n/locales/en.ts
  41. 1 0
      frontend/src/main.tsx
  42. 98 0
      frontend/src/pages/ArchivesPage.tsx
  43. 616 0
      frontend/src/pages/MaintenancePage.tsx
  44. 65 2
      frontend/src/pages/PrintersPage.tsx
  45. 208 21
      frontend/src/pages/SettingsPage.tsx
  46. 0 0
      static/assets/index-C2974G18.js
  47. 0 0
      static/assets/index-CKBzHHT0.js
  48. 0 0
      static/assets/index-DJNRCg8M.css
  49. 0 0
      static/assets/index-H_ymON9v.css
  50. 2 2
      static/index.html

+ 76 - 0
README.md

@@ -88,6 +88,13 @@ v∆v
   - Configurable event triggers (start, complete, failed, stopped, progress milestones)
   - Quiet hours to suppress notifications during sleep
   - Per-printer filtering
+- **Spoolman Integration** - Sync AMS filament data with your Spoolman server
+  - Automatic or manual sync modes
+  - Per-printer or all-printer sync
+  - Auto-creates spools and filaments in Spoolman
+  - Matches Bambu Lab spools by unique tray UUID
+  - Tracks filament usage during prints
+  - Third-party spools (SpoolEase, etc.) gracefully skipped
 - **Cloud Profiles Sync** - Access your Bambu Cloud slicer presets
 - **File Manager** - Browse and manage files on your printer's SD card
 - **Re-print** - Send archived prints back to any connected printer
@@ -127,6 +134,11 @@ v∆v
   <br><em>Cloud Profiles</em>
 </p>
 
+<p align="center">
+  <img src="docs/screenshots/maintenance.png" alt="Maintenance" width="800">
+  <br><em>Maintenance</em>
+</p>
+
 <p align="center">
   <img src="docs/screenshots/settings.png" alt="Settings" width="800">
   <br><em>Settings</em>
@@ -598,6 +610,67 @@ By default, notifications are sent for all printers. To limit notifications to a
 2. Select a printer from the **Printer** dropdown
 3. Only events from that printer will trigger notifications
 
+### Spoolman Integration
+
+Bambusy integrates with [Spoolman](https://github.com/Donkie/Spoolman) for filament inventory management. When enabled, AMS filament data syncs with your Spoolman server, allowing you to track remaining filament across all your spools.
+
+#### Prerequisites
+
+- A running Spoolman server (self-hosted or Docker)
+- Bambu Lab spools with original RFID tags in your AMS
+- Spools registered in Spoolman with matching filament types
+
+#### Setting Up Spoolman
+
+1. Go to **Settings** > scroll to **Spoolman Integration**
+2. Enable the **Enable Spoolman** toggle
+3. Enter your Spoolman server URL (e.g., `http://192.168.1.100:7912`)
+4. Click **Save**
+5. Click **Connect** to establish the connection
+
+#### Sync Modes
+
+| Mode | Description |
+|------|-------------|
+| **Automatic** | AMS data syncs automatically when changes are detected (filament loaded/unloaded, usage during prints) |
+| **Manual Only** | Only sync when you click the Sync button |
+
+#### Manual Sync
+
+When connected:
+1. Select a specific printer from the dropdown, or "All Printers"
+2. Click **Sync** to sync AMS data to Spoolman
+3. Results show how many trays were synced
+
+#### How Syncing Works
+
+Bambusy matches AMS spools to Spoolman spools using the **tray UUID** - a unique 32-character identifier that Bambu Lab assigns to each original spool. This ensures consistent matching across different printer models.
+
+**What gets synced:**
+- Remaining filament weight (from AMS sensor)
+- Filament usage during prints (deducted from Spoolman inventory)
+
+**Auto-Creation:**
+- When a Bambu Lab spool is detected that doesn't exist in Spoolman, it's automatically created
+- Filament types are matched by material and color, or created if needed
+- The "Bambu Lab" vendor is auto-created if it doesn't exist
+- New spools include a comment noting they were auto-created
+
+**Limitations:**
+- Only **Bambu Lab original spools** can be synced (they have valid tray UUIDs)
+- Third-party spools (SpoolEase, refilled spools, etc.) are gracefully skipped - they won't cause errors
+
+#### Troubleshooting Spoolman
+
+**Third-party spools showing in AMS:**
+- SpoolEase and other third-party spools are automatically skipped during sync
+- This is normal behavior - they don't have Bambu Lab tray UUIDs
+
+**Connection issues:**
+- Verify the Spoolman URL is accessible from your Bambusy server
+- Check that no firewall is blocking port 7912 (or your custom port)
+- Ensure Spoolman is running and healthy
+
 #### Provider Setup Guides
 
 **WhatsApp (CallMeBot):**
@@ -799,8 +872,11 @@ To fix the printer's clock:
 - [x] Automatic finish photo capture
 - [x] K-Profiles management (pressure advance)
 - [x] Push notifications (WhatsApp, ntfy, Pushover, Telegram, Email)
+- [x] Spoolman integration (filament inventory sync)
 - [ ] Maintenance tracker
+- [ ] Full printer control
 - [ ] Mobile-optimized UI
+- [ ] docs: readme -> wiki
 
 ## License
 

+ 227 - 0
backend/app/api/routes/archives.py

@@ -60,6 +60,7 @@ def archive_to_response(
         "content_hash": archive.content_hash,
         "thumbnail_path": archive.thumbnail_path,
         "timelapse_path": archive.timelapse_path,
+        "source_3mf_path": archive.source_3mf_path,
         "duplicates": duplicates,
         "duplicate_count": duplicate_count if duplicates is None else len(duplicates),
         "print_name": archive.print_name,
@@ -1390,3 +1391,229 @@ async def get_project_image(
         media_type=content_type,
         headers={"Cache-Control": "max-age=3600"},
     )
+
+
+# =============================================================================
+# Source 3MF API (Original Project Files)
+# =============================================================================
+
+@router.post("/{archive_id}/source")
+async def upload_source_3mf(
+    archive_id: int,
+    file: UploadFile = File(...),
+    db: AsyncSession = Depends(get_db),
+):
+    """Upload the original source 3MF project file for an archive."""
+    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")
+
+    if not file.filename or not file.filename.endswith(".3mf"):
+        raise HTTPException(400, "File must be a .3mf file")
+
+    # Get archive directory and create source subdirectory
+    file_path = settings.base_dir / archive.file_path
+    archive_dir = file_path.parent
+    source_dir = archive_dir / "source"
+    source_dir.mkdir(exist_ok=True)
+
+    # Delete old source file if exists
+    if archive.source_3mf_path:
+        old_source_path = settings.base_dir / archive.source_3mf_path
+        if old_source_path.exists():
+            old_source_path.unlink()
+
+    # Save the source 3MF file - preserve original filename
+    source_filename = file.filename
+    source_path = source_dir / source_filename
+
+    content = await file.read()
+    source_path.write_bytes(content)
+
+    # Update archive with source path (relative to base_dir)
+    archive.source_3mf_path = str(source_path.relative_to(settings.base_dir))
+
+    await db.commit()
+    await db.refresh(archive)
+
+    return {
+        "status": "uploaded",
+        "source_3mf_path": archive.source_3mf_path,
+        "filename": source_filename,
+    }
+
+
+@router.get("/{archive_id}/source")
+async def download_source_3mf(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Download the source 3MF project file."""
+    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")
+
+    if not archive.source_3mf_path:
+        raise HTTPException(404, "No source 3MF attached to this archive")
+
+    source_path = settings.base_dir / archive.source_3mf_path
+    if not source_path.exists():
+        raise HTTPException(404, "Source 3MF file not found on disk")
+
+    # Use the actual filename from the path
+    filename = source_path.name
+
+    return FileResponse(
+        path=source_path,
+        filename=filename,
+        media_type="application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
+    )
+
+
+@router.get("/{archive_id}/source/{filename}")
+async def download_source_3mf_for_slicer(
+    archive_id: int,
+    filename: str,
+    db: AsyncSession = Depends(get_db),
+):
+    """Download source 3MF with filename in URL (for Bambu Studio compatibility)."""
+    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")
+
+    if not archive.source_3mf_path:
+        raise HTTPException(404, "No source 3MF attached to this archive")
+
+    source_path = settings.base_dir / archive.source_3mf_path
+    if not source_path.exists():
+        raise HTTPException(404, "Source 3MF file not found on disk")
+
+    return FileResponse(
+        path=source_path,
+        filename=filename if filename.endswith(".3mf") else f"{filename}.3mf",
+        media_type="application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
+    )
+
+
+@router.post("/upload-source")
+async def upload_source_3mf_by_name(
+    file: UploadFile = File(...),
+    print_name: str = Query(None, description="Match archive by print name"),
+    db: AsyncSession = Depends(get_db),
+):
+    """Upload source 3MF and match to archive by print name.
+
+    This endpoint is designed for slicer post-processing scripts.
+    It finds the most recent archive matching the print name and attaches the source.
+    """
+    if not file.filename or not file.filename.endswith(".3mf"):
+        raise HTTPException(400, "File must be a .3mf file")
+
+    # Derive print name from filename if not provided
+    if not print_name:
+        # Remove .3mf extension and common suffixes
+        print_name = file.filename.rsplit('.3mf', 1)[0]
+        # Remove _source suffix if present
+        if print_name.endswith('_source'):
+            print_name = print_name[:-7]
+
+    # Find matching archive - try exact match first, then fuzzy
+    result = await db.execute(
+        select(PrintArchive)
+        .where(PrintArchive.print_name == print_name)
+        .order_by(PrintArchive.created_at.desc())
+        .limit(1)
+    )
+    archive = result.scalar_one_or_none()
+
+    if not archive:
+        # Try matching filename without .gcode.3mf
+        result = await db.execute(
+            select(PrintArchive)
+            .where(PrintArchive.filename.like(f"{print_name}%"))
+            .order_by(PrintArchive.created_at.desc())
+            .limit(1)
+        )
+        archive = result.scalar_one_or_none()
+
+    if not archive:
+        # Try case-insensitive partial match on print_name
+        result = await db.execute(
+            select(PrintArchive)
+            .where(PrintArchive.print_name.ilike(f"%{print_name}%"))
+            .order_by(PrintArchive.created_at.desc())
+            .limit(1)
+        )
+        archive = result.scalar_one_or_none()
+
+    if not archive:
+        raise HTTPException(404, f"No archive found matching '{print_name}'")
+
+    # Get archive directory and create source subdirectory
+    file_path = settings.base_dir / archive.file_path
+    archive_dir = file_path.parent
+    source_dir = archive_dir / "source"
+    source_dir.mkdir(exist_ok=True)
+
+    # Delete old source file if exists
+    if archive.source_3mf_path:
+        old_source_path = settings.base_dir / archive.source_3mf_path
+        if old_source_path.exists():
+            old_source_path.unlink()
+
+    # Save the source 3MF file - preserve original filename
+    source_filename = file.filename
+    source_path = source_dir / source_filename
+
+    content = await file.read()
+    source_path.write_bytes(content)
+
+    # Update archive with source path
+    archive.source_3mf_path = str(source_path.relative_to(settings.base_dir))
+    await db.commit()
+    await db.refresh(archive)
+
+    return {
+        "status": "uploaded",
+        "archive_id": archive.id,
+        "archive_name": archive.print_name or archive.filename,
+        "source_3mf_path": archive.source_3mf_path,
+        "filename": source_filename,
+    }
+
+
+@router.delete("/{archive_id}/source")
+async def delete_source_3mf(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Delete the source 3MF project file from an archive."""
+    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")
+
+    if not archive.source_3mf_path:
+        raise HTTPException(404, "No source 3MF attached to this archive")
+
+    # Delete the file
+    source_path = settings.base_dir / archive.source_3mf_path
+    if source_path.exists():
+        source_path.unlink()
+
+    # Clear the path in database
+    archive.source_3mf_path = None
+    await db.commit()
+
+    return {"status": "deleted"}

+ 520 - 0
backend/app/api/routes/maintenance.py

@@ -0,0 +1,520 @@
+"""Maintenance tracking API routes."""
+
+import logging
+from datetime import datetime
+from typing import List
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select, func
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from backend.app.core.database import get_db
+from backend.app.models.maintenance import MaintenanceType, PrinterMaintenance, MaintenanceHistory
+from backend.app.models.printer import Printer
+from backend.app.models.archive import PrintArchive
+from backend.app.services.notification_service import notification_service
+from backend.app.schemas.maintenance import (
+    MaintenanceTypeCreate,
+    MaintenanceTypeUpdate,
+    MaintenanceTypeResponse,
+    PrinterMaintenanceCreate,
+    PrinterMaintenanceUpdate,
+    PrinterMaintenanceResponse,
+    MaintenanceHistoryResponse,
+    MaintenanceStatus,
+    PrinterMaintenanceOverview,
+    PerformMaintenanceRequest,
+)
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/maintenance", tags=["maintenance"])
+
+# Default maintenance types
+DEFAULT_MAINTENANCE_TYPES = [
+    {
+        "name": "Lubricate Linear Rails",
+        "description": "Apply lubricant to linear rails and rods for smooth motion",
+        "default_interval_hours": 50.0,
+        "icon": "Droplet",
+    },
+    {
+        "name": "Clean Nozzle/Hotend",
+        "description": "Clean nozzle exterior and perform cold pull if needed",
+        "default_interval_hours": 100.0,
+        "icon": "Flame",
+    },
+    {
+        "name": "Check Belt Tension",
+        "description": "Verify and adjust belt tension for X/Y axes",
+        "default_interval_hours": 200.0,
+        "icon": "Ruler",
+    },
+    {
+        "name": "Clean Carbon Rods",
+        "description": "Wipe carbon rods with a dry cloth",
+        "default_interval_hours": 100.0,
+        "icon": "Sparkles",
+    },
+    {
+        "name": "Clean Build Plate",
+        "description": "Deep clean build plate with IPA or soap",
+        "default_interval_hours": 25.0,
+        "icon": "Square",
+    },
+    {
+        "name": "Check PTFE Tube",
+        "description": "Inspect PTFE tube for wear or discoloration",
+        "default_interval_hours": 500.0,
+        "icon": "Cable",
+    },
+]
+
+
+async def get_printer_total_hours(db: AsyncSession, printer_id: int) -> float:
+    """Calculate total print hours for a printer from archives plus offset."""
+    # Get archive hours
+    result = await db.execute(
+        select(func.sum(PrintArchive.print_time_seconds))
+        .where(PrintArchive.printer_id == printer_id)
+        .where(PrintArchive.status == "completed")
+    )
+    total_seconds = result.scalar() or 0
+    archive_hours = total_seconds / 3600.0
+
+    # Get printer offset
+    result = await db.execute(
+        select(Printer.print_hours_offset).where(Printer.id == printer_id)
+    )
+    offset = result.scalar() or 0.0
+
+    return archive_hours + offset
+
+
+async def ensure_default_types(db: AsyncSession) -> None:
+    """Ensure default maintenance types exist."""
+    result = await db.execute(
+        select(MaintenanceType).where(MaintenanceType.is_system == True)
+    )
+    existing = result.scalars().all()
+    existing_names = {t.name for t in existing}
+
+    for type_def in DEFAULT_MAINTENANCE_TYPES:
+        if type_def["name"] not in existing_names:
+            new_type = MaintenanceType(
+                name=type_def["name"],
+                description=type_def["description"],
+                default_interval_hours=type_def["default_interval_hours"],
+                icon=type_def["icon"],
+                is_system=True,
+            )
+            db.add(new_type)
+
+    await db.commit()
+
+
+# ============== Maintenance Types ==============
+
+@router.get("/types", response_model=List[MaintenanceTypeResponse])
+async def get_maintenance_types(db: AsyncSession = Depends(get_db)):
+    """Get all maintenance types."""
+    await ensure_default_types(db)
+    result = await db.execute(
+        select(MaintenanceType).order_by(MaintenanceType.is_system.desc(), MaintenanceType.name)
+    )
+    return result.scalars().all()
+
+
+@router.post("/types", response_model=MaintenanceTypeResponse)
+async def create_maintenance_type(
+    data: MaintenanceTypeCreate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Create a custom maintenance type."""
+    new_type = MaintenanceType(
+        name=data.name,
+        description=data.description,
+        default_interval_hours=data.default_interval_hours,
+        icon=data.icon,
+        is_system=False,
+    )
+    db.add(new_type)
+    await db.commit()
+    await db.refresh(new_type)
+    return new_type
+
+
+@router.patch("/types/{type_id}", response_model=MaintenanceTypeResponse)
+async def update_maintenance_type(
+    type_id: int,
+    data: MaintenanceTypeUpdate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Update a maintenance type."""
+    result = await db.execute(
+        select(MaintenanceType).where(MaintenanceType.id == type_id)
+    )
+    maint_type = result.scalar_one_or_none()
+    if not maint_type:
+        raise HTTPException(status_code=404, detail="Maintenance type not found")
+
+    update_data = data.model_dump(exclude_unset=True)
+    for key, value in update_data.items():
+        setattr(maint_type, key, value)
+
+    await db.commit()
+    await db.refresh(maint_type)
+    return maint_type
+
+
+@router.delete("/types/{type_id}")
+async def delete_maintenance_type(
+    type_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Delete a custom maintenance type."""
+    result = await db.execute(
+        select(MaintenanceType).where(MaintenanceType.id == type_id)
+    )
+    maint_type = result.scalar_one_or_none()
+    if not maint_type:
+        raise HTTPException(status_code=404, detail="Maintenance type not found")
+
+    if maint_type.is_system:
+        raise HTTPException(status_code=400, detail="Cannot delete system maintenance type")
+
+    await db.delete(maint_type)
+    await db.commit()
+    return {"status": "deleted"}
+
+
+# ============== Printer Maintenance ==============
+
+async def _get_printer_maintenance_internal(
+    printer_id: int,
+    db: AsyncSession,
+    commit: bool = True,
+) -> PrinterMaintenanceOverview:
+    """Internal helper to get maintenance overview for a specific printer."""
+    await ensure_default_types(db)
+
+    # Get printer
+    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")
+
+    total_hours = await get_printer_total_hours(db, printer_id)
+
+    # Get all maintenance types
+    result = await db.execute(select(MaintenanceType))
+    all_types = result.scalars().all()
+
+    # Get printer's maintenance items
+    result = await db.execute(
+        select(PrinterMaintenance)
+        .where(PrinterMaintenance.printer_id == printer_id)
+        .options(selectinload(PrinterMaintenance.maintenance_type))
+    )
+    existing_items = {item.maintenance_type_id: item for item in result.scalars().all()}
+
+    maintenance_items = []
+    due_count = 0
+    warning_count = 0
+
+    for maint_type in all_types:
+        item = existing_items.get(maint_type.id)
+
+        if item:
+            interval = item.custom_interval_hours or maint_type.default_interval_hours
+            enabled = item.enabled
+            last_performed_hours = item.last_performed_hours
+            last_performed_at = item.last_performed_at
+            item_id = item.id
+        else:
+            # Create default entry for this printer/type
+            item = PrinterMaintenance(
+                printer_id=printer_id,
+                maintenance_type_id=maint_type.id,
+                enabled=True,
+                last_performed_hours=0.0,
+            )
+            db.add(item)
+            await db.flush()
+
+            interval = maint_type.default_interval_hours
+            enabled = True
+            last_performed_hours = 0.0
+            last_performed_at = None
+            item_id = item.id
+
+        hours_since = total_hours - last_performed_hours
+        hours_until = interval - hours_since
+        is_due = hours_until <= 0
+        is_warning = hours_until <= (interval * 0.1) and not is_due
+
+        if enabled:
+            if is_due:
+                due_count += 1
+            elif is_warning:
+                warning_count += 1
+
+        maintenance_items.append(MaintenanceStatus(
+            id=item_id,
+            printer_id=printer_id,
+            printer_name=printer.name,
+            maintenance_type_id=maint_type.id,
+            maintenance_type_name=maint_type.name,
+            maintenance_type_icon=maint_type.icon,
+            enabled=enabled,
+            interval_hours=interval,
+            current_hours=total_hours,
+            hours_since_maintenance=hours_since,
+            hours_until_due=hours_until,
+            is_due=is_due,
+            is_warning=is_warning,
+            last_performed_at=last_performed_at,
+        ))
+
+    if commit:
+        await db.commit()
+
+    return PrinterMaintenanceOverview(
+        printer_id=printer_id,
+        printer_name=printer.name,
+        total_print_hours=total_hours,
+        maintenance_items=maintenance_items,
+        due_count=due_count,
+        warning_count=warning_count,
+    )
+
+
+@router.get("/printers/{printer_id}", response_model=PrinterMaintenanceOverview)
+async def get_printer_maintenance(
+    printer_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get maintenance overview for a specific printer."""
+    return await _get_printer_maintenance_internal(printer_id, db, commit=True)
+
+
+@router.get("/overview", response_model=List[PrinterMaintenanceOverview])
+async def get_all_maintenance_overview(db: AsyncSession = Depends(get_db)):
+    """Get maintenance overview for all active printers."""
+    await ensure_default_types(db)
+
+    result = await db.execute(
+        select(Printer).where(Printer.is_active == True)
+    )
+    printers = result.scalars().all()
+
+    overviews = []
+    for printer in printers:
+        # Don't commit after each printer, commit once at the end
+        overview = await _get_printer_maintenance_internal(printer.id, db, commit=False)
+        overviews.append(overview)
+
+    # Commit any new maintenance items created
+    await db.commit()
+
+    return overviews
+
+
+@router.patch("/items/{item_id}", response_model=PrinterMaintenanceResponse)
+async def update_printer_maintenance(
+    item_id: int,
+    data: PrinterMaintenanceUpdate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Update a printer maintenance item (e.g., custom interval, enabled)."""
+    result = await db.execute(
+        select(PrinterMaintenance)
+        .where(PrinterMaintenance.id == item_id)
+        .options(selectinload(PrinterMaintenance.maintenance_type))
+    )
+    item = result.scalar_one_or_none()
+    if not item:
+        raise HTTPException(status_code=404, detail="Maintenance item not found")
+
+    update_data = data.model_dump(exclude_unset=True)
+    for key, value in update_data.items():
+        setattr(item, key, value)
+
+    await db.commit()
+    await db.refresh(item)
+    return item
+
+
+@router.post("/items/{item_id}/perform", response_model=MaintenanceStatus)
+async def perform_maintenance(
+    item_id: int,
+    data: PerformMaintenanceRequest,
+    db: AsyncSession = Depends(get_db),
+):
+    """Mark maintenance as performed (reset the counter)."""
+    result = await db.execute(
+        select(PrinterMaintenance)
+        .where(PrinterMaintenance.id == item_id)
+        .options(selectinload(PrinterMaintenance.maintenance_type))
+    )
+    item = result.scalar_one_or_none()
+    if not item:
+        raise HTTPException(status_code=404, detail="Maintenance item not found")
+
+    # Get printer for name
+    result = await db.execute(
+        select(Printer).where(Printer.id == item.printer_id)
+    )
+    printer = result.scalar_one()
+
+    # Get current hours
+    current_hours = await get_printer_total_hours(db, item.printer_id)
+
+    # Create history entry
+    history = MaintenanceHistory(
+        printer_maintenance_id=item.id,
+        hours_at_maintenance=current_hours,
+        notes=data.notes,
+    )
+    db.add(history)
+
+    # Update item
+    item.last_performed_at = datetime.utcnow()
+    item.last_performed_hours = current_hours
+
+    await db.commit()
+
+    # Calculate status
+    interval = item.custom_interval_hours or item.maintenance_type.default_interval_hours
+    hours_since = current_hours - item.last_performed_hours
+    hours_until = interval - hours_since
+
+    return MaintenanceStatus(
+        id=item.id,
+        printer_id=item.printer_id,
+        printer_name=printer.name,
+        maintenance_type_id=item.maintenance_type_id,
+        maintenance_type_name=item.maintenance_type.name,
+        maintenance_type_icon=item.maintenance_type.icon,
+        enabled=item.enabled,
+        interval_hours=interval,
+        current_hours=current_hours,
+        hours_since_maintenance=hours_since,
+        hours_until_due=hours_until,
+        is_due=False,
+        is_warning=False,
+        last_performed_at=item.last_performed_at,
+    )
+
+
+@router.get("/items/{item_id}/history", response_model=List[MaintenanceHistoryResponse])
+async def get_maintenance_history(
+    item_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get maintenance history for a specific item."""
+    result = await db.execute(
+        select(MaintenanceHistory)
+        .where(MaintenanceHistory.printer_maintenance_id == item_id)
+        .order_by(MaintenanceHistory.performed_at.desc())
+    )
+    return result.scalars().all()
+
+
+@router.get("/summary")
+async def get_maintenance_summary(db: AsyncSession = Depends(get_db)):
+    """Get a summary of maintenance status across all printers."""
+    await ensure_default_types(db)
+
+    result = await db.execute(
+        select(Printer).where(Printer.is_active == True)
+    )
+    printers = result.scalars().all()
+
+    total_due = 0
+    total_warning = 0
+    printers_with_issues = []
+
+    for printer in printers:
+        overview = await get_printer_maintenance(printer.id, db)
+        total_due += overview.due_count
+        total_warning += overview.warning_count
+        if overview.due_count > 0 or overview.warning_count > 0:
+            printers_with_issues.append({
+                "printer_id": printer.id,
+                "printer_name": printer.name,
+                "due_count": overview.due_count,
+                "warning_count": overview.warning_count,
+            })
+
+    return {
+        "total_due": total_due,
+        "total_warning": total_warning,
+        "printers_with_issues": printers_with_issues,
+    }
+
+
+@router.patch("/printers/{printer_id}/hours")
+async def set_printer_hours(
+    printer_id: int,
+    total_hours: float,
+    db: AsyncSession = Depends(get_db),
+):
+    """Set the total print hours for a printer (adjusts offset to match)."""
+    # Get printer
+    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 archive hours
+    result = await db.execute(
+        select(func.sum(PrintArchive.print_time_seconds))
+        .where(PrintArchive.printer_id == printer_id)
+        .where(PrintArchive.status == "completed")
+    )
+    total_seconds = result.scalar() or 0
+    archive_hours = total_seconds / 3600.0
+
+    # Calculate needed offset
+    printer.print_hours_offset = max(0, total_hours - archive_hours)
+
+    await db.commit()
+
+    # Check for maintenance items that need attention and send notification
+    try:
+        await ensure_default_types(db)
+        overview = await _get_printer_maintenance_internal(printer_id, db, commit=True)
+
+        items_needing_attention = [
+            {
+                "name": item.maintenance_type_name,
+                "is_due": item.is_due,
+                "is_warning": item.is_warning,
+            }
+            for item in overview.maintenance_items
+            if item.enabled and (item.is_due or item.is_warning)
+        ]
+
+        if items_needing_attention:
+            await notification_service.on_maintenance_due(
+                printer_id, printer.name, items_needing_attention, db
+            )
+            logger.info(
+                f"Sent maintenance notification for printer {printer_id}: "
+                f"{len(items_needing_attention)} items need attention"
+            )
+    except Exception as e:
+        logger.warning(f"Failed to send maintenance notification: {e}")
+
+    return {
+        "printer_id": printer_id,
+        "total_hours": total_hours,
+        "archive_hours": archive_hours,
+        "offset_hours": printer.print_hours_offset,
+    }

+ 2 - 0
backend/app/api/routes/notifications.py

@@ -35,11 +35,13 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
         "on_print_start": provider.on_print_start,
         "on_print_complete": provider.on_print_complete,
         "on_print_failed": provider.on_print_failed,
+        "on_print_stopped": provider.on_print_stopped,
         "on_print_progress": provider.on_print_progress,
         # Printer status events
         "on_printer_offline": provider.on_printer_offline,
         "on_printer_error": provider.on_printer_error,
         "on_filament_low": provider.on_filament_low,
+        "on_maintenance_due": provider.on_maintenance_due,
         # Quiet hours
         "quiet_hours_enabled": provider.quiet_hours_enabled,
         "quiet_hours_start": provider.quiet_hours_start,

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

@@ -46,7 +46,7 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
     for setting in db_settings:
         if setting.key in settings_dict:
             # Parse the value based on the expected type
-            if setting.key in ["auto_archive", "save_thumbnails", "capture_finish_photo"]:
+            if setting.key in ["auto_archive", "save_thumbnails", "capture_finish_photo", "spoolman_enabled", "check_updates"]:
                 settings_dict[setting.key] = setting.value.lower() == "true"
             elif setting.key in ["default_filament_cost", "energy_cost_per_kwh"]:
                 settings_dict[setting.key] = float(setting.value)
@@ -99,3 +99,36 @@ async def check_ffmpeg():
         "installed": ffmpeg_path is not None,
         "path": ffmpeg_path,
     }
+
+
+@router.get("/spoolman")
+async def get_spoolman_settings(db: AsyncSession = Depends(get_db)):
+    """Get Spoolman integration settings."""
+    spoolman_enabled = await get_setting(db, "spoolman_enabled") or "false"
+    spoolman_url = await get_setting(db, "spoolman_url") or ""
+    spoolman_sync_mode = await get_setting(db, "spoolman_sync_mode") or "auto"
+
+    return {
+        "spoolman_enabled": spoolman_enabled,
+        "spoolman_url": spoolman_url,
+        "spoolman_sync_mode": spoolman_sync_mode,
+    }
+
+
+@router.put("/spoolman")
+async def update_spoolman_settings(
+    settings: dict,
+    db: AsyncSession = Depends(get_db),
+):
+    """Update Spoolman integration settings."""
+    if "spoolman_enabled" in settings:
+        await set_setting(db, "spoolman_enabled", settings["spoolman_enabled"])
+    if "spoolman_url" in settings:
+        await set_setting(db, "spoolman_url", settings["spoolman_url"])
+    if "spoolman_sync_mode" in settings:
+        await set_setting(db, "spoolman_sync_mode", settings["spoolman_sync_mode"])
+
+    await db.commit()
+
+    # Return updated settings
+    return await get_spoolman_settings(db)

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

@@ -0,0 +1,351 @@
+"""Spoolman integration API routes."""
+
+import logging
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+from pydantic import BaseModel
+
+from backend.app.core.database import get_db
+from backend.app.models.printer import Printer
+from backend.app.models.settings import Settings
+from backend.app.services.spoolman import (
+    SpoolmanClient,
+    get_spoolman_client,
+    init_spoolman_client,
+    close_spoolman_client,
+)
+from backend.app.services.printer_manager import printer_manager
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/spoolman", tags=["spoolman"])
+
+
+class SpoolmanStatus(BaseModel):
+    """Spoolman connection status."""
+
+    enabled: bool
+    connected: bool
+    url: str | None
+
+
+class SyncResult(BaseModel):
+    """Result of a Spoolman sync operation."""
+
+    success: bool
+    synced_count: int
+    errors: list[str]
+
+
+async def get_spoolman_settings(db: AsyncSession) -> tuple[bool, str, str]:
+    """Get Spoolman settings from database.
+
+    Returns:
+        Tuple of (enabled, url, sync_mode)
+    """
+    enabled = False
+    url = ""
+    sync_mode = "auto"
+
+    result = await db.execute(select(Settings))
+    for setting in result.scalars().all():
+        if setting.key == "spoolman_enabled":
+            enabled = setting.value.lower() == "true"
+        elif setting.key == "spoolman_url":
+            url = setting.value
+        elif setting.key == "spoolman_sync_mode":
+            sync_mode = setting.value
+
+    return enabled, url, sync_mode
+
+
+@router.get("/status", response_model=SpoolmanStatus)
+async def get_spoolman_status(db: AsyncSession = Depends(get_db)):
+    """Get Spoolman integration status."""
+    enabled, url, _ = await get_spoolman_settings(db)
+
+    client = await get_spoolman_client()
+    connected = False
+    if client:
+        connected = await client.health_check()
+
+    return SpoolmanStatus(
+        enabled=enabled,
+        connected=connected,
+        url=url if url else None,
+    )
+
+
+@router.post("/connect")
+async def connect_spoolman(db: AsyncSession = Depends(get_db)):
+    """Connect to Spoolman server using configured URL."""
+    enabled, url, _ = await get_spoolman_settings(db)
+
+    if not enabled:
+        raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
+
+    if not url:
+        raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
+
+    try:
+        client = await init_spoolman_client(url)
+        connected = await client.health_check()
+
+        if not connected:
+            raise HTTPException(
+                status_code=503,
+                detail=f"Could not connect to Spoolman at {url}",
+            )
+
+        return {"success": True, "message": f"Connected to Spoolman at {url}"}
+    except Exception as e:
+        logger.error(f"Failed to connect to Spoolman: {e}")
+        raise HTTPException(status_code=503, detail=str(e))
+
+
+@router.post("/disconnect")
+async def disconnect_spoolman():
+    """Disconnect from Spoolman server."""
+    await close_spoolman_client()
+    return {"success": True, "message": "Disconnected from Spoolman"}
+
+
+@router.post("/sync/{printer_id}", response_model=SyncResult)
+async def sync_printer_ams(
+    printer_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Sync AMS data from a specific printer to Spoolman."""
+    # Check if Spoolman is enabled and connected
+    enabled, url, _ = await get_spoolman_settings(db)
+    if not enabled:
+        raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
+
+    client = await get_spoolman_client()
+    if not client:
+        # Try to connect
+        if url:
+            client = await init_spoolman_client(url)
+        else:
+            raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
+
+    if not await client.health_check():
+        raise HTTPException(status_code=503, detail="Spoolman is not reachable")
+
+    # Get printer info
+    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 printer state with AMS data
+    state = printer_manager.get_status(printer_id)
+    if not state:
+        raise HTTPException(status_code=404, detail="Printer not connected")
+
+    if not state.raw_data:
+        raise HTTPException(status_code=400, detail="No AMS data available")
+
+    ams_data = state.raw_data.get("ams")
+    if not ams_data:
+        raise HTTPException(
+            status_code=400,
+            detail="No AMS data in printer state. Try triggering a slot re-read on the printer.",
+        )
+
+    # Sync each AMS tray to Spoolman
+    synced = 0
+    errors = []
+
+    # Handle different AMS data structures
+    # Traditional AMS: list of {"id": N, "tray": [...]} dicts
+    # H2D/newer printers: dict with different structure
+    ams_units = []
+    if isinstance(ams_data, list):
+        ams_units = ams_data
+    elif isinstance(ams_data, dict):
+        # H2D format: check for "ams" key containing list, or "tray" key directly
+        if "ams" in ams_data and isinstance(ams_data["ams"], list):
+            ams_units = ams_data["ams"]
+        elif "tray" in ams_data:
+            # Single AMS unit format - wrap in list
+            ams_units = [{"id": 0, "tray": ams_data.get("tray", [])}]
+        else:
+            logger.info(f"AMS dict keys for debugging: {list(ams_data.keys())}")
+
+    if not ams_units:
+        raise HTTPException(
+            status_code=400,
+            detail=f"AMS data format not supported. Keys: {list(ams_data.keys()) if isinstance(ams_data, dict) else type(ams_data).__name__}",
+        )
+
+    for ams_unit in ams_units:
+        if not isinstance(ams_unit, dict):
+            continue
+
+        ams_id = int(ams_unit.get("id", 0))
+        trays = ams_unit.get("tray", [])
+
+        for tray_data in trays:
+            if not isinstance(tray_data, dict):
+                continue
+
+            tray = client.parse_ams_tray(ams_id, tray_data)
+            if not tray:
+                continue  # Empty tray
+
+            # Skip non-Bambu Lab spools (SpoolEase/third-party) - this is not an error
+            if not client.is_bambu_lab_spool(tray.tray_uuid):
+                continue
+
+            try:
+                sync_result = await client.sync_ams_tray(tray, printer.name)
+                if sync_result:
+                    synced += 1
+                    logger.info(
+                        f"Synced {tray.tray_sub_brands} from {printer.name} AMS {ams_id} tray {tray.tray_id}"
+                    )
+                else:
+                    # Bambu Lab spool that wasn't synced (not found in Spoolman)
+                    errors.append(f"Spool not found in Spoolman: AMS {ams_id}:{tray.tray_id}")
+            except Exception as e:
+                error_msg = f"Error syncing AMS {ams_id} tray {tray.tray_id}: {e}"
+                logger.error(error_msg)
+                errors.append(error_msg)
+
+    return SyncResult(
+        success=len(errors) == 0,
+        synced_count=synced,
+        errors=errors,
+    )
+
+
+@router.post("/sync-all", response_model=SyncResult)
+async def sync_all_printers(db: AsyncSession = Depends(get_db)):
+    """Sync AMS data from all connected printers to Spoolman."""
+    # Check if Spoolman is enabled
+    enabled, url, _ = await get_spoolman_settings(db)
+    if not enabled:
+        raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
+
+    client = await get_spoolman_client()
+    if not client:
+        if url:
+            client = await init_spoolman_client(url)
+        else:
+            raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
+
+    if not await client.health_check():
+        raise HTTPException(status_code=503, detail="Spoolman is not reachable")
+
+    # Get all active printers
+    result = await db.execute(select(Printer).where(Printer.is_active == True))
+    printers = result.scalars().all()
+
+    total_synced = 0
+    all_errors = []
+
+    for printer in printers:
+        state = printer_manager.get_status(printer.id)
+        if not state or not state.raw_data:
+            continue
+
+        ams_data = state.raw_data.get("ams")
+        if not ams_data:
+            continue
+
+        # Handle different AMS data structures
+        # Traditional AMS: list of {"id": N, "tray": [...]} dicts
+        # H2D/newer printers: dict with different structure
+        ams_units = []
+        if isinstance(ams_data, list):
+            ams_units = ams_data
+        elif isinstance(ams_data, dict):
+            # H2D format: check for "ams" key containing list, or "tray" key directly
+            if "ams" in ams_data and isinstance(ams_data["ams"], list):
+                ams_units = ams_data["ams"]
+            elif "tray" in ams_data:
+                # Single AMS unit format - wrap in list
+                ams_units = [{"id": 0, "tray": ams_data.get("tray", [])}]
+            else:
+                logger.debug(f"Printer {printer.name} AMS dict keys: {list(ams_data.keys())}")
+
+        if not ams_units:
+            logger.debug(f"Printer {printer.name} has no AMS units to sync (type: {type(ams_data).__name__})")
+            continue
+
+        for ams_unit in ams_units:
+            if not isinstance(ams_unit, dict):
+                logger.debug(f"Skipping non-dict AMS unit: {type(ams_unit)}")
+                continue
+
+            ams_id = int(ams_unit.get("id", 0))
+            trays = ams_unit.get("tray", [])
+
+            for tray_data in trays:
+                if not isinstance(tray_data, dict):
+                    continue
+
+                tray = client.parse_ams_tray(ams_id, tray_data)
+                if not tray:
+                    continue
+
+                # Skip non-Bambu Lab spools (SpoolEase/third-party) - this is not an error
+                if not client.is_bambu_lab_spool(tray.tray_uuid):
+                    continue
+
+                try:
+                    sync_result = await client.sync_ams_tray(tray, printer.name)
+                    if sync_result:
+                        total_synced += 1
+                except Exception as e:
+                    all_errors.append(f"{printer.name} AMS {ams_id}:{tray.tray_id}: {e}")
+
+    return SyncResult(
+        success=len(all_errors) == 0,
+        synced_count=total_synced,
+        errors=all_errors,
+    )
+
+
+@router.get("/spools")
+async def get_spools(db: AsyncSession = Depends(get_db)):
+    """Get all spools from Spoolman."""
+    enabled, url, _ = await get_spoolman_settings(db)
+    if not enabled:
+        raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
+
+    client = await get_spoolman_client()
+    if not client:
+        if url:
+            client = await init_spoolman_client(url)
+        else:
+            raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
+
+    if not await client.health_check():
+        raise HTTPException(status_code=503, detail="Spoolman is not reachable")
+
+    spools = await client.get_spools()
+    return {"spools": spools}
+
+
+@router.get("/filaments")
+async def get_filaments(db: AsyncSession = Depends(get_db)):
+    """Get all filaments from Spoolman."""
+    enabled, url, _ = await get_spoolman_settings(db)
+    if not enabled:
+        raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
+
+    client = await get_spoolman_client()
+    if not client:
+        if url:
+            client = await init_spoolman_client(url)
+        else:
+            raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
+
+    if not await client.health_check():
+        raise HTTPException(status_code=503, detail="Spoolman is not reachable")
+
+    filaments = await client.get_filaments()
+    return {"filaments": filaments}

+ 276 - 0
backend/app/api/routes/updates.py

@@ -0,0 +1,276 @@
+"""Update checking and management routes."""
+
+import asyncio
+import logging
+import subprocess
+import sys
+from pathlib import Path
+
+import httpx
+from fastapi import APIRouter, BackgroundTasks, Depends
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.config import APP_VERSION, GITHUB_REPO, settings
+from backend.app.core.database import get_db
+from backend.app.api.routes.settings import get_setting
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/updates", tags=["updates"])
+
+# Global state for update progress
+_update_status = {
+    "status": "idle",  # idle, checking, downloading, installing, complete, error
+    "progress": 0,
+    "message": "",
+    "error": None,
+}
+
+
+def parse_version(version: str) -> tuple[int, ...]:
+    """Parse version string into tuple for comparison."""
+    # Remove 'v' prefix if present
+    version = version.lstrip("v")
+    # Split and convert to integers
+    parts = []
+    for part in version.split("."):
+        try:
+            parts.append(int(part))
+        except ValueError:
+            # Handle pre-release versions like "1.0.0-beta"
+            num = "".join(c for c in part if c.isdigit())
+            parts.append(int(num) if num else 0)
+    return tuple(parts)
+
+
+def is_newer_version(latest: str, current: str) -> bool:
+    """Check if latest version is newer than current."""
+    try:
+        return parse_version(latest) > parse_version(current)
+    except Exception:
+        return False
+
+
+@router.get("/version")
+async def get_version():
+    """Get current application version."""
+    return {
+        "version": APP_VERSION,
+        "repo": GITHUB_REPO,
+    }
+
+
+@router.get("/check")
+async def check_for_updates(db: AsyncSession = Depends(get_db)):
+    """Check GitHub for available updates."""
+    global _update_status
+
+    _update_status = {
+        "status": "checking",
+        "progress": 0,
+        "message": "Checking for updates...",
+        "error": None,
+    }
+
+    try:
+        async with httpx.AsyncClient() as client:
+            response = await client.get(
+                f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest",
+                headers={"Accept": "application/vnd.github.v3+json"},
+                timeout=10.0,
+            )
+
+            if response.status_code == 404:
+                # No releases yet
+                _update_status = {
+                    "status": "idle",
+                    "progress": 100,
+                    "message": "No releases found",
+                    "error": None,
+                }
+                return {
+                    "update_available": False,
+                    "current_version": APP_VERSION,
+                    "latest_version": None,
+                    "message": "No releases found",
+                }
+
+            response.raise_for_status()
+            release_data = response.json()
+
+            latest_version = release_data.get("tag_name", "").lstrip("v")
+            release_name = release_data.get("name", latest_version)
+            release_notes = release_data.get("body", "")
+            release_url = release_data.get("html_url", "")
+            published_at = release_data.get("published_at", "")
+
+            update_available = is_newer_version(latest_version, APP_VERSION)
+
+            _update_status = {
+                "status": "idle",
+                "progress": 100,
+                "message": "Update available" if update_available else "Up to date",
+                "error": None,
+            }
+
+            return {
+                "update_available": update_available,
+                "current_version": APP_VERSION,
+                "latest_version": latest_version,
+                "release_name": release_name,
+                "release_notes": release_notes,
+                "release_url": release_url,
+                "published_at": published_at,
+            }
+
+    except httpx.HTTPError as e:
+        logger.error(f"Failed to check for updates: {e}")
+        _update_status = {
+            "status": "error",
+            "progress": 0,
+            "message": "Failed to check for updates",
+            "error": str(e),
+        }
+        return {
+            "update_available": False,
+            "current_version": APP_VERSION,
+            "latest_version": None,
+            "error": str(e),
+        }
+
+
+async def _perform_update():
+    """Perform the actual update using git pull."""
+    global _update_status
+
+    try:
+        _update_status = {
+            "status": "downloading",
+            "progress": 20,
+            "message": "Pulling latest changes...",
+            "error": None,
+        }
+
+        # Run git pull in the project directory
+        base_dir = settings.base_dir
+        process = await asyncio.create_subprocess_exec(
+            "git", "pull", "--rebase",
+            cwd=str(base_dir),
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE,
+        )
+        stdout, stderr = await process.communicate()
+
+        if process.returncode != 0:
+            error_msg = stderr.decode() if stderr else "Git pull failed"
+            logger.error(f"Git pull failed: {error_msg}")
+            _update_status = {
+                "status": "error",
+                "progress": 0,
+                "message": "Failed to pull updates",
+                "error": error_msg,
+            }
+            return
+
+        _update_status = {
+            "status": "installing",
+            "progress": 50,
+            "message": "Installing dependencies...",
+            "error": None,
+        }
+
+        # Install Python dependencies
+        process = await asyncio.create_subprocess_exec(
+            sys.executable, "-m", "pip", "install", "-r", "requirements.txt", "-q",
+            cwd=str(base_dir),
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE,
+        )
+        stdout, stderr = await process.communicate()
+
+        if process.returncode != 0:
+            logger.warning(f"pip install warning: {stderr.decode() if stderr else 'unknown'}")
+
+        _update_status = {
+            "status": "installing",
+            "progress": 70,
+            "message": "Building frontend...",
+            "error": None,
+        }
+
+        # Build frontend
+        frontend_dir = base_dir / "frontend"
+        if frontend_dir.exists():
+            # npm install
+            process = await asyncio.create_subprocess_exec(
+                "npm", "install",
+                cwd=str(frontend_dir),
+                stdout=asyncio.subprocess.PIPE,
+                stderr=asyncio.subprocess.PIPE,
+            )
+            await process.communicate()
+
+            # npm run build
+            process = await asyncio.create_subprocess_exec(
+                "npm", "run", "build",
+                cwd=str(frontend_dir),
+                stdout=asyncio.subprocess.PIPE,
+                stderr=asyncio.subprocess.PIPE,
+            )
+            stdout, stderr = await process.communicate()
+
+            if process.returncode != 0:
+                logger.warning(f"Frontend build warning: {stderr.decode() if stderr else 'unknown'}")
+
+        _update_status = {
+            "status": "complete",
+            "progress": 100,
+            "message": "Update complete! Please restart the application.",
+            "error": None,
+        }
+
+        logger.info("Update completed successfully")
+
+    except Exception as e:
+        logger.error(f"Update failed: {e}")
+        _update_status = {
+            "status": "error",
+            "progress": 0,
+            "message": "Update failed",
+            "error": str(e),
+        }
+
+
+@router.post("/apply")
+async def apply_update(background_tasks: BackgroundTasks):
+    """Apply available update (git pull + rebuild)."""
+    global _update_status
+
+    if _update_status["status"] in ["downloading", "installing"]:
+        return {
+            "success": False,
+            "message": "Update already in progress",
+            "status": _update_status,
+        }
+
+    # Start update in background
+    background_tasks.add_task(_perform_update)
+
+    _update_status = {
+        "status": "downloading",
+        "progress": 10,
+        "message": "Starting update...",
+        "error": None,
+    }
+
+    return {
+        "success": True,
+        "message": "Update started",
+        "status": _update_status,
+    }
+
+
+@router.get("/status")
+async def get_update_status():
+    """Get current update status."""
+    return _update_status

+ 4 - 0
backend/app/core/config.py

@@ -1,6 +1,10 @@
 from pathlib import Path
 from pydantic_settings import BaseSettings
 
+# Application version - single source of truth
+APP_VERSION = "0.1.4"
+GITHUB_REPO = "maziggy/bambusy"
+
 
 class Settings(BaseSettings):
     app_name: str = "BambuTrack"

+ 19 - 1
backend/app/core/database.py

@@ -34,7 +34,7 @@ async def get_db() -> AsyncSession:
 
 async def init_db():
     # Import models to register them with SQLAlchemy
-    from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification  # noqa: F401
+    from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification, maintenance  # noqa: F401
 
     async with engine.begin() as conn:
         await conn.run_sync(Base.metadata.create_all)
@@ -82,3 +82,21 @@ async def run_migrations(conn):
     except Exception:
         # Column already exists
         pass
+
+    # Migration: Add source_3mf_path column to print_archives
+    try:
+        await conn.execute(text(
+            "ALTER TABLE print_archives ADD COLUMN source_3mf_path VARCHAR(500)"
+        ))
+    except Exception:
+        # Column already exists
+        pass
+
+    # Migration: Add on_maintenance_due column to notification_providers
+    try:
+        await conn.execute(text(
+            "ALTER TABLE notification_providers ADD COLUMN on_maintenance_due BOOLEAN DEFAULT 0"
+        ))
+    except Exception:
+        # Column already exists
+        pass

+ 133 - 0
backend/app/i18n/__init__.py

@@ -0,0 +1,133 @@
+"""Internationalization module for backend notifications."""
+
+from typing import Any
+
+# English translations
+EN = {
+    "notification": {
+        # Print events
+        "print_started": "Print Started",
+        "print_completed": "Print Completed",
+        "print_failed": "Print Failed",
+        "print_stopped": "Print Stopped",
+        "print_ended": "Print Ended",
+        "print_progress": "Print {progress}% Complete",
+        "estimated": "Estimated",
+        "time": "Time",
+        "filament": "Filament",
+        "reason": "Reason",
+        "unknown": "Unknown",
+
+        # Printer events
+        "printer_offline": "Printer Offline",
+        "printer_disconnected": "{printer} has disconnected",
+        "printer_error": "Printer Error: {error_type}",
+
+        # Filament
+        "filament_low": "Filament Low",
+        "slot_at_percent": "{printer}: Slot {slot} at {percent}%",
+
+        # Maintenance
+        "maintenance_due": "Maintenance Due",
+        "overdue": "OVERDUE",
+        "soon": "Soon",
+
+        # Test notification
+        "test_title": "BambuTrack Test",
+        "test_message": "This is a test notification from BambuTrack. If you see this, notifications are working correctly!",
+    }
+}
+
+# German translations
+DE = {
+    "notification": {
+        # Print events
+        "print_started": "Druck gestartet",
+        "print_completed": "Druck abgeschlossen",
+        "print_failed": "Druck fehlgeschlagen",
+        "print_stopped": "Druck gestoppt",
+        "print_ended": "Druck beendet",
+        "print_progress": "Druck {progress}% fertig",
+        "estimated": "Geschätzt",
+        "time": "Zeit",
+        "filament": "Filament",
+        "reason": "Grund",
+        "unknown": "Unbekannt",
+
+        # Printer events
+        "printer_offline": "Drucker offline",
+        "printer_disconnected": "{printer} wurde getrennt",
+        "printer_error": "Druckerfehler: {error_type}",
+
+        # Filament
+        "filament_low": "Wenig Filament",
+        "slot_at_percent": "{printer}: Slot {slot} bei {percent}%",
+
+        # Maintenance
+        "maintenance_due": "Wartung fällig",
+        "overdue": "ÜBERFÄLLIG",
+        "soon": "Bald",
+
+        # Test notification
+        "test_title": "BambuTrack Test",
+        "test_message": "Dies ist eine Testbenachrichtigung von BambuTrack. Wenn Sie dies sehen, funktionieren die Benachrichtigungen!",
+    }
+}
+
+# All available translations
+TRANSLATIONS = {
+    "en": EN,
+    "de": DE,
+}
+
+
+def get_translation(lang: str, key: str, **kwargs: Any) -> str:
+    """
+    Get a translation string by key with optional interpolation.
+
+    Args:
+        lang: Language code (e.g., 'en', 'de')
+        key: Dot-separated key path (e.g., 'notification.print_started')
+        **kwargs: Values to interpolate into the string
+
+    Returns:
+        Translated string, or the key if not found
+    """
+    # Fall back to English if language not found
+    translations = TRANSLATIONS.get(lang, TRANSLATIONS["en"])
+
+    # Navigate to the nested key
+    keys = key.split(".")
+    value = translations
+    for k in keys:
+        if isinstance(value, dict) and k in value:
+            value = value[k]
+        else:
+            # Key not found, fall back to English
+            value = TRANSLATIONS["en"]
+            for k2 in keys:
+                if isinstance(value, dict) and k2 in value:
+                    value = value[k2]
+                else:
+                    return key  # Return key if not found in fallback either
+            break
+
+    if isinstance(value, str):
+        # Interpolate values
+        try:
+            return value.format(**kwargs)
+        except KeyError:
+            return value
+
+    return key
+
+
+class Translator:
+    """Helper class for translations with a specific language."""
+
+    def __init__(self, lang: str = "en"):
+        self.lang = lang if lang in TRANSLATIONS else "en"
+
+    def t(self, key: str, **kwargs: Any) -> str:
+        """Translate a key."""
+        return get_translation(self.lang, key, **kwargs)

+ 227 - 3
backend/app/main.py

@@ -9,7 +9,7 @@ from logging.handlers import RotatingFileHandler
 from fastapi import FastAPI
 
 # Import settings first for logging configuration
-from backend.app.core.config import settings as app_settings
+from backend.app.core.config import settings as app_settings, APP_VERSION
 
 # Configure logging based on settings
 # DEBUG=true -> DEBUG level, else use LOG_LEVEL setting
@@ -54,7 +54,7 @@ from fastapi.responses import FileResponse
 from backend.app.core.database import init_db, async_session
 from sqlalchemy import select, or_
 from backend.app.core.websocket import ws_manager
-from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications
+from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, spoolman, updates, maintenance
 from backend.app.api.routes import settings as settings_routes
 from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import (
@@ -69,6 +69,8 @@ from backend.app.services.bambu_ftp import download_file_async
 from backend.app.services.smart_plug_manager import smart_plug_manager
 from backend.app.services.tasmota import tasmota_service
 from backend.app.models.smart_plug import SmartPlug
+from backend.app.services.spoolman import get_spoolman_client, init_spoolman_client, close_spoolman_client
+from backend.app.api.routes.maintenance import _get_printer_maintenance_internal, ensure_default_types
 
 
 # Track active prints: {(printer_id, filename): archive_id}
@@ -99,6 +101,92 @@ def register_expected_print(printer_id: int, filename: str, archive_id: int):
 _last_status_broadcast: dict[int, str] = {}
 _nozzle_count_updated: set[int] = set()  # Track printers where we've updated nozzle_count
 
+
+async def _report_spoolman_usage(printer_id: int, archive_id: int, logger):
+    """Report filament usage to Spoolman after print completion.
+
+    This finds the spool by RFID tag_uid from current AMS state and reports
+    the filament_used_grams from the archive metadata.
+    """
+    async with async_session() as db:
+        from backend.app.api.routes.settings import get_setting
+        from backend.app.models.archive import PrintArchive
+
+        # Check if Spoolman is enabled
+        spoolman_enabled = await get_setting(db, "spoolman_enabled")
+        if not spoolman_enabled or spoolman_enabled.lower() != "true":
+            return
+
+        # Get Spoolman URL
+        spoolman_url = await get_setting(db, "spoolman_url")
+        if not spoolman_url:
+            return
+
+        # Get or create Spoolman client
+        client = await get_spoolman_client()
+        if not client:
+            client = await init_spoolman_client(spoolman_url)
+
+        # Check if Spoolman is reachable
+        if not await client.health_check():
+            logger.warning(f"Spoolman not reachable for usage reporting")
+            return
+
+        # Get archive to find filament usage
+        result = await db.execute(
+            select(PrintArchive).where(PrintArchive.id == archive_id)
+        )
+        archive = result.scalar_one_or_none()
+        if not archive or not archive.filament_used_grams:
+            logger.debug(f"No filament usage data for archive {archive_id}")
+            return
+
+        filament_used = archive.filament_used_grams
+        logger.info(f"[SPOOLMAN] Archive {archive_id} used {filament_used}g of filament")
+
+        # Get current AMS state from printer to find the active spool
+        state = printer_manager.get_status(printer_id)
+        if not state or not state.raw_data:
+            logger.debug(f"No printer state available for usage reporting")
+            return
+
+        ams_data = state.raw_data.get("ams")
+        if not ams_data:
+            logger.debug(f"No AMS data available for usage reporting")
+            return
+
+        # Find spools with RFID tags in Spoolman and report usage
+        # For now, we report usage to the first spool found with a matching tag
+        # TODO: In future, track which specific trays were used during the print
+        spools_updated = 0
+        for ams_unit in ams_data:
+            ams_id = int(ams_unit.get("id", 0))
+            trays = ams_unit.get("tray", [])
+
+            for tray_data in trays:
+                tag_uid = tray_data.get("tag_uid")
+                if not tag_uid:
+                    continue
+
+                # Find spool in Spoolman by tag
+                spool = await client.find_spool_by_tag(tag_uid)
+                if spool:
+                    # Report usage to Spoolman
+                    result = await client.use_spool(spool["id"], filament_used)
+                    if result:
+                        logger.info(
+                            f"[SPOOLMAN] Reported {filament_used}g usage to spool {spool['id']} "
+                            f"(tag: {tag_uid})"
+                        )
+                        spools_updated += 1
+                        # Only report to one spool for single-material prints
+                        # Multi-material prints would need more sophisticated tracking
+                        return
+
+        if spools_updated == 0:
+            logger.debug(f"No matching Spoolman spools found for printer {printer_id}")
+
+
 async def on_printer_status_change(printer_id: int, state: PrinterState):
     """Handle printer status changes - broadcast via WebSocket."""
     # Only broadcast if something meaningful changed (reduce WebSocket spam)
@@ -141,6 +229,74 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
     )
 
 
+async def on_ams_change(printer_id: int, ams_data: list):
+    """Handle AMS data changes - sync to Spoolman if enabled and auto mode."""
+    import logging
+    logger = logging.getLogger(__name__)
+
+    try:
+        async with async_session() as db:
+            from backend.app.api.routes.settings import get_setting
+            from backend.app.models.printer import Printer
+
+            # Check if Spoolman is enabled
+            spoolman_enabled = await get_setting(db, "spoolman_enabled")
+            if not spoolman_enabled or spoolman_enabled.lower() != "true":
+                return
+
+            # Check sync mode
+            sync_mode = await get_setting(db, "spoolman_sync_mode")
+            if sync_mode and sync_mode != "auto":
+                return  # Only sync on auto mode
+
+            # Get Spoolman URL
+            spoolman_url = await get_setting(db, "spoolman_url")
+            if not spoolman_url:
+                return
+
+            # Get or create Spoolman client
+            client = await get_spoolman_client()
+            if not client:
+                client = await init_spoolman_client(spoolman_url)
+
+            # Check if Spoolman is reachable
+            if not await client.health_check():
+                logger.warning(f"Spoolman not reachable at {spoolman_url}")
+                return
+
+            # Get printer name for location
+            result = await db.execute(
+                select(Printer).where(Printer.id == printer_id)
+            )
+            printer = result.scalar_one_or_none()
+            printer_name = printer.name if printer else f"Printer {printer_id}"
+
+            # Sync each AMS tray
+            synced = 0
+            for ams_unit in ams_data:
+                ams_id = int(ams_unit.get("id", 0))
+                trays = ams_unit.get("tray", [])
+
+                for tray_data in trays:
+                    tray = client.parse_ams_tray(ams_id, tray_data)
+                    if not tray:
+                        continue  # Empty tray
+
+                    try:
+                        result = await client.sync_ams_tray(tray, printer_name)
+                        if result:
+                            synced += 1
+                    except Exception as e:
+                        logger.error(f"Error syncing AMS {ams_id} tray {tray.tray_id}: {e}")
+
+            if synced > 0:
+                logger.info(f"Auto-synced {synced} AMS trays to Spoolman for printer {printer_id}")
+
+    except Exception as e:
+        import logging
+        logging.getLogger(__name__).warning(f"Spoolman AMS sync failed: {e}")
+
+
 async def on_print_start(printer_id: int, data: dict):
     """Handle print start - archive the 3MF file immediately."""
     import logging
@@ -570,6 +726,13 @@ async def on_print_complete(printer_id: int, data: dict):
             "status": status,
         })
 
+    # Report filament usage to Spoolman if print completed successfully
+    if data.get("status") == "completed":
+        try:
+            await _report_spoolman_usage(printer_id, archive_id, logger)
+        except Exception as e:
+            logger.warning(f"Spoolman usage reporting failed: {e}")
+
     # Calculate energy used for this print (always per-print: end - start)
     try:
         starting_kwh = _print_energy_start.pop(archive_id, None)
@@ -702,6 +865,46 @@ async def on_print_complete(printer_id: int, data: dict):
         import logging
         logging.getLogger(__name__).warning(f"Notification on_print_complete failed: {e}")
 
+    # Check for maintenance due and send notifications (only for completed prints)
+    if data.get("status") == "completed":
+        try:
+            async with async_session() as db:
+                from backend.app.models.printer import Printer
+
+                # Get printer name
+                result = await db.execute(
+                    select(Printer).where(Printer.id == printer_id)
+                )
+                printer = result.scalar_one_or_none()
+                printer_name = printer.name if printer else f"Printer {printer_id}"
+
+                # Get maintenance overview for this printer
+                await ensure_default_types(db)
+                overview = await _get_printer_maintenance_internal(printer_id, db, commit=True)
+
+                # Check for any items that are due or have warnings
+                items_needing_attention = [
+                    {
+                        "name": item.maintenance_type_name,
+                        "is_due": item.is_due,
+                        "is_warning": item.is_warning,
+                    }
+                    for item in overview.maintenance_items
+                    if item.enabled and (item.is_due or item.is_warning)
+                ]
+
+                if items_needing_attention:
+                    await notification_service.on_maintenance_due(
+                        printer_id, printer_name, items_needing_attention, db
+                    )
+                    logger.info(
+                        f"Sent maintenance notification for printer {printer_id}: "
+                        f"{len(items_needing_attention)} items need attention"
+                    )
+        except Exception as e:
+            import logging
+            logging.getLogger(__name__).warning(f"Maintenance notification check failed: {e}")
+
     # Update queue item if this was a scheduled print
     try:
         async with async_session() as db:
@@ -765,11 +968,28 @@ async def lifespan(app: FastAPI):
     printer_manager.set_status_change_callback(on_printer_status_change)
     printer_manager.set_print_start_callback(on_print_start)
     printer_manager.set_print_complete_callback(on_print_complete)
+    printer_manager.set_ams_change_callback(on_ams_change)
 
     # Connect to all active printers
     async with async_session() as db:
         await init_printer_connections(db)
 
+    # Auto-connect to Spoolman if enabled
+    async with async_session() as db:
+        from backend.app.api.routes.settings import get_setting
+        spoolman_enabled = await get_setting(db, "spoolman_enabled")
+        spoolman_url = await get_setting(db, "spoolman_url")
+
+        if spoolman_enabled and spoolman_enabled.lower() == "true" and spoolman_url:
+            try:
+                client = await init_spoolman_client(spoolman_url)
+                if await client.health_check():
+                    logging.info(f"Auto-connected to Spoolman at {spoolman_url}")
+                else:
+                    logging.warning(f"Spoolman at {spoolman_url} is not reachable")
+            except Exception as e:
+                logging.warning(f"Failed to auto-connect to Spoolman: {e}")
+
     # Start the print scheduler
     asyncio.create_task(print_scheduler.run())
 
@@ -778,12 +998,13 @@ async def lifespan(app: FastAPI):
     # Shutdown
     print_scheduler.stop()
     printer_manager.disconnect_all()
+    await close_spoolman_client()
 
 
 app = FastAPI(
     title=app_settings.app_name,
     description="Archive and manage Bambu Lab 3MF files",
-    version="0.1.2",
+    version=APP_VERSION,
     lifespan=lifespan,
 )
 
@@ -797,6 +1018,9 @@ app.include_router(smart_plugs.router, prefix=app_settings.api_prefix)
 app.include_router(print_queue.router, prefix=app_settings.api_prefix)
 app.include_router(kprofiles.router, prefix=app_settings.api_prefix)
 app.include_router(notifications.router, prefix=app_settings.api_prefix)
+app.include_router(spoolman.router, prefix=app_settings.api_prefix)
+app.include_router(updates.router, prefix=app_settings.api_prefix)
+app.include_router(maintenance.router, prefix=app_settings.api_prefix)
 app.include_router(websocket.router, prefix=app_settings.api_prefix)
 
 

+ 11 - 1
backend/app/models/__init__.py

@@ -3,5 +3,15 @@ from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
 from backend.app.models.settings import Settings
 from backend.app.models.smart_plug import SmartPlug
+from backend.app.models.maintenance import MaintenanceType, PrinterMaintenance, MaintenanceHistory
 
-__all__ = ["Printer", "PrintArchive", "Filament", "Settings", "SmartPlug"]
+__all__ = [
+    "Printer",
+    "PrintArchive",
+    "Filament",
+    "Settings",
+    "SmartPlug",
+    "MaintenanceType",
+    "PrinterMaintenance",
+    "MaintenanceHistory",
+]

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

@@ -18,6 +18,7 @@ class PrintArchive(Base):
     content_hash: Mapped[str | None] = mapped_column(String(64))  # SHA256 hash for duplicate detection
     thumbnail_path: Mapped[str | None] = mapped_column(String(500))
     timelapse_path: Mapped[str | None] = mapped_column(String(500))
+    source_3mf_path: Mapped[str | None] = mapped_column(String(500))  # Original project 3MF from slicer
 
     # Print details from 3MF / printer
     print_name: Mapped[str | None] = mapped_column(String(255))

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

@@ -0,0 +1,78 @@
+"""Maintenance tracking models."""
+
+from datetime import datetime
+from sqlalchemy import String, Boolean, DateTime, Integer, Float, ForeignKey, Text, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+
+class MaintenanceType(Base):
+    """Defines a type of maintenance task with default interval."""
+    __tablename__ = "maintenance_types"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(100))
+    description: Mapped[str | None] = mapped_column(Text)
+    default_interval_hours: Mapped[float] = mapped_column(Float, default=100.0)
+    icon: Mapped[str | None] = mapped_column(String(50))  # Icon name for UI
+    is_system: Mapped[bool] = mapped_column(Boolean, default=False)  # Pre-defined vs custom
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime, server_default=func.now()
+    )
+
+    # Relationships
+    printer_maintenance: Mapped[list["PrinterMaintenance"]] = relationship(
+        back_populates="maintenance_type", cascade="all, delete-orphan"
+    )
+
+
+class PrinterMaintenance(Base):
+    """Tracks maintenance status for a specific printer."""
+    __tablename__ = "printer_maintenance"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"))
+    maintenance_type_id: Mapped[int] = mapped_column(ForeignKey("maintenance_types.id", ondelete="CASCADE"))
+
+    # Custom interval for this printer (overrides default if set)
+    custom_interval_hours: Mapped[float | None] = mapped_column(Float, nullable=True)
+
+    # Tracking
+    enabled: Mapped[bool] = mapped_column(Boolean, default=True)
+    last_performed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    last_performed_hours: Mapped[float] = mapped_column(Float, default=0.0)  # Hours at last reset
+
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime, server_default=func.now()
+    )
+    updated_at: Mapped[datetime] = mapped_column(
+        DateTime, server_default=func.now(), onupdate=func.now()
+    )
+
+    # Relationships
+    printer: Mapped["Printer"] = relationship(back_populates="maintenance_items")
+    maintenance_type: Mapped["MaintenanceType"] = relationship(back_populates="printer_maintenance")
+    history: Mapped[list["MaintenanceHistory"]] = relationship(
+        back_populates="printer_maintenance", cascade="all, delete-orphan"
+    )
+
+
+class MaintenanceHistory(Base):
+    """Log of maintenance actions performed."""
+    __tablename__ = "maintenance_history"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    printer_maintenance_id: Mapped[int] = mapped_column(
+        ForeignKey("printer_maintenance.id", ondelete="CASCADE")
+    )
+    performed_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    hours_at_maintenance: Mapped[float] = mapped_column(Float, default=0.0)
+    notes: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+    # Relationships
+    printer_maintenance: Mapped["PrinterMaintenance"] = relationship(back_populates="history")
+
+
+# Import at end to avoid circular imports
+from backend.app.models.printer import Printer  # noqa: E402

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

@@ -32,6 +32,7 @@ class NotificationProvider(Base):
     on_printer_offline = Column(Boolean, default=False)
     on_printer_error = Column(Boolean, default=False)  # AMS issues, etc.
     on_filament_low = Column(Boolean, default=False)
+    on_maintenance_due = Column(Boolean, default=False)  # Maintenance reminder
 
     # Quiet hours (do not disturb)
     quiet_hours_enabled = Column(Boolean, default=False)

+ 6 - 1
backend/app/models/printer.py

@@ -1,5 +1,5 @@
 from datetime import datetime
-from sqlalchemy import String, Boolean, DateTime, func
+from sqlalchemy import String, Boolean, DateTime, Float, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
@@ -17,6 +17,7 @@ class Printer(Base):
     nozzle_count: Mapped[int] = mapped_column(default=1)  # 1 or 2, auto-detected from MQTT
     is_active: Mapped[bool] = mapped_column(Boolean, default=True)
     auto_archive: Mapped[bool] = mapped_column(Boolean, default=True)
+    print_hours_offset: Mapped[float] = mapped_column(Float, default=0.0)  # Baseline hours to add
     created_at: Mapped[datetime] = mapped_column(
         DateTime, server_default=func.now()
     )
@@ -34,8 +35,12 @@ class Printer(Base):
     notification_providers: Mapped[list["NotificationProvider"]] = relationship(
         back_populates="printer"
     )
+    maintenance_items: Mapped[list["PrinterMaintenance"]] = relationship(
+        back_populates="printer", cascade="all, delete-orphan"
+    )
 
 
 from backend.app.models.archive import PrintArchive  # noqa: E402
 from backend.app.models.smart_plug import SmartPlug  # noqa: E402
 from backend.app.models.notification import NotificationProvider  # noqa: E402
+from backend.app.models.maintenance import PrinterMaintenance  # noqa: E402

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

@@ -32,6 +32,7 @@ class ArchiveResponse(BaseModel):
     content_hash: str | None
     thumbnail_path: str | None
     timelapse_path: str | None
+    source_3mf_path: str | None = None  # Original project 3MF from slicer
 
     # Duplicate detection
     duplicates: list[ArchiveDuplicate] | None = None

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

@@ -0,0 +1,118 @@
+"""Maintenance tracking schemas."""
+
+from datetime import datetime
+from pydantic import BaseModel, Field
+
+
+# Maintenance Type schemas
+class MaintenanceTypeBase(BaseModel):
+    name: str = Field(..., min_length=1, max_length=100)
+    description: str | None = None
+    default_interval_hours: float = Field(default=100.0, ge=1.0)
+    icon: str | None = None
+
+
+class MaintenanceTypeCreate(MaintenanceTypeBase):
+    pass
+
+
+class MaintenanceTypeUpdate(BaseModel):
+    name: str | None = None
+    description: str | None = None
+    default_interval_hours: float | None = Field(default=None, ge=1.0)
+    icon: str | None = None
+
+
+class MaintenanceTypeResponse(MaintenanceTypeBase):
+    id: int
+    is_system: bool
+    created_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
+# Printer Maintenance schemas
+class PrinterMaintenanceBase(BaseModel):
+    printer_id: int
+    maintenance_type_id: int
+    custom_interval_hours: float | None = None
+    enabled: bool = True
+
+
+class PrinterMaintenanceCreate(PrinterMaintenanceBase):
+    pass
+
+
+class PrinterMaintenanceUpdate(BaseModel):
+    custom_interval_hours: float | None = None
+    enabled: bool | None = None
+
+
+class PrinterMaintenanceResponse(BaseModel):
+    id: int
+    printer_id: int
+    maintenance_type_id: int
+    maintenance_type: MaintenanceTypeResponse
+    custom_interval_hours: float | None
+    enabled: bool
+    last_performed_at: datetime | None
+    last_performed_hours: float
+    created_at: datetime
+    updated_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
+# Maintenance History schemas
+class MaintenanceHistoryBase(BaseModel):
+    notes: str | None = None
+
+
+class MaintenanceHistoryCreate(MaintenanceHistoryBase):
+    pass
+
+
+class MaintenanceHistoryResponse(MaintenanceHistoryBase):
+    id: int
+    printer_maintenance_id: int
+    performed_at: datetime
+    hours_at_maintenance: float
+
+    class Config:
+        from_attributes = True
+
+
+# Combined status response for frontend
+class MaintenanceStatus(BaseModel):
+    """Maintenance status for a printer with calculated values."""
+    id: int
+    printer_id: int
+    printer_name: str
+    maintenance_type_id: int
+    maintenance_type_name: str
+    maintenance_type_icon: str | None
+    enabled: bool
+    interval_hours: float  # custom or default
+    current_hours: float  # total print hours for printer
+    hours_since_maintenance: float  # current - last_performed
+    hours_until_due: float  # interval - hours_since
+    is_due: bool  # hours_until_due <= 0
+    is_warning: bool  # hours_until_due <= 10% of interval
+    last_performed_at: datetime | None
+
+
+class PrinterMaintenanceOverview(BaseModel):
+    """Overview of all maintenance items for a printer."""
+    printer_id: int
+    printer_name: str
+    total_print_hours: float
+    maintenance_items: list[MaintenanceStatus]
+    due_count: int
+    warning_count: int
+
+
+class PerformMaintenanceRequest(BaseModel):
+    """Request to mark maintenance as performed."""
+    notes: str | None = None

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

@@ -36,6 +36,7 @@ class NotificationProviderBase(BaseModel):
     on_printer_offline: bool = Field(default=False, description="Notify when printer goes offline")
     on_printer_error: bool = Field(default=False, description="Notify on printer errors (AMS, etc.)")
     on_filament_low: bool = Field(default=False, description="Notify when filament is running low")
+    on_maintenance_due: bool = Field(default=False, description="Notify when maintenance is due")
 
     # Quiet hours
     quiet_hours_enabled: bool = Field(default=False, description="Enable quiet hours")
@@ -87,6 +88,7 @@ class NotificationProviderUpdate(BaseModel):
     on_printer_offline: bool | None = None
     on_printer_error: bool | None = None
     on_filament_low: bool | None = None
+    on_maintenance_due: bool | None = None
 
     # Quiet hours
     quiet_hours_enabled: bool | None = None

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

@@ -22,12 +22,14 @@ class PrinterUpdate(BaseModel):
     model: str | None = None
     is_active: bool | None = None
     auto_archive: bool | None = None
+    print_hours_offset: float | None = None
 
 
 class PrinterResponse(PrinterBase):
     id: int
     is_active: bool
     nozzle_count: int = 1  # 1 or 2, auto-detected from MQTT
+    print_hours_offset: float = 0.0
     created_at: datetime
     updated_at: datetime
 

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

@@ -12,6 +12,17 @@ class AppSettings(BaseModel):
     energy_cost_per_kwh: float = Field(default=0.15, description="Electricity cost per kWh for energy tracking")
     energy_tracking_mode: str = Field(default="total", description="Energy display mode on stats: 'print' shows sum of per-print energy, 'total' shows lifetime plug consumption")
 
+    # Spoolman integration
+    spoolman_enabled: bool = Field(default=False, description="Enable Spoolman integration for filament tracking")
+    spoolman_url: str = Field(default="", description="Spoolman server URL (e.g., http://localhost:7912)")
+    spoolman_sync_mode: str = Field(default="auto", description="Sync mode: 'auto' syncs immediately, 'manual' requires button press")
+
+    # Updates
+    check_updates: bool = Field(default=True, description="Automatically check for updates on startup")
+
+    # Language
+    notification_language: str = Field(default="en", description="Language for push notifications (en, de)")
+
 
 class AppSettingsUpdate(BaseModel):
     """Schema for updating settings (all fields optional)."""
@@ -23,3 +34,8 @@ class AppSettingsUpdate(BaseModel):
     currency: str | None = None
     energy_cost_per_kwh: float | None = None
     energy_tracking_mode: str | None = None
+    spoolman_enabled: bool | None = None
+    spoolman_url: str | None = None
+    spoolman_sync_mode: str | None = None
+    check_updates: bool | None = None
+    notification_language: str | None = None

+ 71 - 4
backend/app/services/bambu_mqtt.py

@@ -78,6 +78,7 @@ class BambuMQTTClient:
         on_state_change: Callable[[PrinterState], None] | None = None,
         on_print_start: Callable[[dict], None] | None = None,
         on_print_complete: Callable[[dict], None] | None = None,
+        on_ams_change: Callable[[list], None] | None = None,
     ):
         self.ip_address = ip_address
         self.serial_number = serial_number
@@ -85,6 +86,7 @@ class BambuMQTTClient:
         self.on_state_change = on_state_change
         self.on_print_start = on_print_start
         self.on_print_complete = on_print_complete
+        self.on_ams_change = on_ams_change
 
         self.state = PrinterState()
         self._client: mqtt.Client | None = None
@@ -96,6 +98,7 @@ class BambuMQTTClient:
         self._message_log: deque[MQTTLogEntry] = deque(maxlen=100)
         self._logging_enabled: bool = False
         self._last_message_time: float = 0.0  # Track when we last received a message
+        self._previous_ams_hash: str | None = None  # Track AMS changes
 
         # K-profile command tracking
         self._sequence_id: int = 0
@@ -157,6 +160,14 @@ class BambuMQTTClient:
 
     def _process_message(self, payload: dict):
         """Process incoming MQTT message from printer."""
+        # Handle top-level AMS data (comes outside of "print" key)
+        # Wrap in try/except to prevent breaking the MQTT connection
+        if "ams" in payload:
+            try:
+                self._handle_ams_data(payload["ams"])
+            except Exception as e:
+                logger.error(f"[{self.serial_number}] Error handling AMS data: {e}")
+
         if "print" in payload:
             print_data = payload["print"]
             # Log when we see gcode_state changes
@@ -167,11 +178,45 @@ class BambuMQTTClient:
                 )
 
             # Check for K-profile response (extrusion_cali)
+            if "command" in print_data:
+                logger.debug(f"[{self.serial_number}] Received command response: {print_data.get('command')}")
             if "command" in print_data and print_data.get("command") == "extrusion_cali_get":
                 self._handle_kprofile_response(print_data)
 
             self._update_state(print_data)
 
+    def _handle_ams_data(self, ams_data: list):
+        """Handle AMS data changes for Spoolman integration.
+
+        This is called when we receive top-level AMS data in MQTT messages.
+        It detects changes and triggers the callback for Spoolman sync.
+        """
+        import hashlib
+
+        # Store AMS data in raw_data so it's accessible via API
+        if "ams" not in self.state.raw_data:
+            self.state.raw_data["ams"] = ams_data
+        else:
+            self.state.raw_data["ams"] = ams_data
+
+        # Create a hash of relevant AMS data to detect changes
+        ams_hash_data = []
+        for ams_unit in ams_data:
+            for tray in ams_unit.get("tray", []):
+                # Include fields that matter for filament tracking
+                ams_hash_data.append(
+                    f"{ams_unit.get('id')}:{tray.get('id')}:"
+                    f"{tray.get('tray_type')}:{tray.get('tag_uid')}:{tray.get('remain')}"
+                )
+        ams_hash = hashlib.md5(":".join(ams_hash_data).encode()).hexdigest()
+
+        # Only trigger callback if AMS data actually changed
+        if ams_hash != self._previous_ams_hash:
+            self._previous_ams_hash = ams_hash
+            if self.on_ams_change:
+                logger.info(f"[{self.serial_number}] AMS data changed, triggering sync callback")
+                self.on_ams_change(ams_data)
+
     def _update_state(self, data: dict):
         """Update printer state from message data."""
         previous_state = self.state.state
@@ -259,7 +304,11 @@ class BambuMQTTClient:
                             severity=severity if severity > 0 else 3,
                         ))
 
+        # Preserve AMS data when updating raw_data (AMS comes at top level, not in print)
+        ams_data = self.state.raw_data.get("ams")
         self.state.raw_data = data
+        if ams_data is not None:
+            self.state.raw_data["ams"] = ams_data
 
         # Log state transitions for debugging
         if "gcode_state" in data:
@@ -363,8 +412,14 @@ class BambuMQTTClient:
             message = {"pushing": {"command": "pushall"}}
             self._client.publish(self.topic_publish, json.dumps(message))
 
-    def connect(self):
-        """Connect to the printer MQTT broker."""
+    def connect(self, loop: asyncio.AbstractEventLoop | None = None):
+        """Connect to the printer MQTT broker.
+
+        Args:
+            loop: The asyncio event loop to use for thread-safe callbacks.
+                  If not provided, will try to get the running loop.
+        """
+        self._loop = loop
         self._client = mqtt.Client(
             callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
             client_id=f"bambutrack_{self.serial_number}",
@@ -503,12 +558,17 @@ class BambuMQTTClient:
         self._kprofile_response_data = profiles
 
         # Signal that we received the response
+        # Use thread-safe method since MQTT callbacks run in a different thread
         if self._pending_kprofile_response:
-            self._pending_kprofile_response.set()
+            if self._loop and self._loop.is_running():
+                self._loop.call_soon_threadsafe(self._pending_kprofile_response.set)
+            else:
+                # Fallback for when loop is not available
+                self._pending_kprofile_response.set()
 
         logger.info(f"[{self.serial_number}] Received {len(profiles)} K-profiles")
 
-    async def get_kprofiles(self, nozzle_diameter: str = "0.4", timeout: float = 5.0) -> list[KProfile]:
+    async def get_kprofiles(self, nozzle_diameter: str = "0.4", timeout: float = 10.0) -> list[KProfile]:
         """Request K-profiles from the printer.
 
         Args:
@@ -522,6 +582,13 @@ class BambuMQTTClient:
             logger.warning(f"[{self.serial_number}] Cannot get K-profiles: not connected")
             return []
 
+        # Capture current event loop for thread-safe callback
+        try:
+            self._loop = asyncio.get_running_loop()
+        except RuntimeError:
+            logger.warning(f"[{self.serial_number}] No running event loop")
+            return []
+
         # Set up response event
         self._sequence_id += 1
         self._pending_kprofile_response = asyncio.Event()

+ 96 - 35
backend/app/services/notification_service.py

@@ -14,6 +14,8 @@ from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.models.notification import NotificationProvider
+from backend.app.models.settings import Settings
+from backend.app.i18n import Translator
 
 logger = logging.getLogger(__name__)
 
@@ -64,70 +66,78 @@ class NotificationService:
             logger.warning(f"Invalid quiet hours format for provider {provider.name}")
             return False
 
-    def _format_duration(self, seconds: int | None) -> str:
+    async def _get_notification_language(self, db: AsyncSession) -> str:
+        """Get the notification language from settings."""
+        result = await db.execute(
+            select(Settings).where(Settings.key == "notification_language")
+        )
+        setting = result.scalar_one_or_none()
+        return setting.value if setting else "en"
+
+    def _format_duration(self, seconds: int | None, translator: Translator) -> str:
         """Format duration in seconds to human-readable string."""
         if seconds is None:
-            return "Unknown"
+            return translator.t("notification.unknown")
         hours = seconds // 3600
         minutes = (seconds % 3600) // 60
         if hours > 0:
             return f"{hours}h {minutes}m"
         return f"{minutes}m"
 
-    def _build_print_start_message(self, printer_name: str, data: dict) -> tuple[str, str]:
+    def _build_print_start_message(self, printer_name: str, data: dict, translator: Translator) -> tuple[str, str]:
         """Build notification message for print start event."""
-        filename = data.get("filename", "Unknown")
+        filename = data.get("filename", translator.t("notification.unknown"))
         # Clean up filename
         if filename.endswith(".gcode.3mf"):
             filename = filename[:-10]
         elif filename.endswith(".3mf"):
             filename = filename[:-4]
 
-        title = "Print Started"
+        title = translator.t("notification.print_started")
 
         estimated_time = data.get("raw_data", {}).get("print", {}).get("mc_remaining_time")
-        time_str = self._format_duration(estimated_time * 60 if estimated_time else None)
+        time_str = self._format_duration(estimated_time * 60 if estimated_time else None, translator)
 
-        message = f"{printer_name}: {filename}\nEstimated: {time_str}"
+        message = f"{printer_name}: {filename}\n{translator.t('notification.estimated')}: {time_str}"
         return title, message
 
     def _build_print_complete_message(
-        self, printer_name: str, status: str, data: dict, archive_data: dict | None = None
+        self, printer_name: str, status: str, data: dict, translator: Translator, archive_data: dict | None = None
     ) -> tuple[str, str]:
         """Build notification message for print complete event."""
-        filename = data.get("filename", "Unknown")
+        filename = data.get("filename", translator.t("notification.unknown"))
         if filename.endswith(".gcode.3mf"):
             filename = filename[:-10]
         elif filename.endswith(".3mf"):
             filename = filename[:-4]
 
         if status == "completed":
-            title = "Print Completed"
+            title = translator.t("notification.print_completed")
         elif status == "failed":
-            title = "Print Failed"
+            title = translator.t("notification.print_failed")
         elif status in ("aborted", "stopped", "cancelled"):
-            title = "Print Stopped"
+            title = translator.t("notification.print_stopped")
         else:
-            title = "Print Ended"
+            title = translator.t("notification.print_ended")
 
         lines = [f"{printer_name}: {filename}"]
 
         if archive_data:
             # Add print time if available
             if archive_data.get("print_time_seconds"):
-                lines.append(f"Time: {self._format_duration(archive_data['print_time_seconds'])}")
+                lines.append(f"{translator.t('notification.time')}: {self._format_duration(archive_data['print_time_seconds'], translator)}")
             # Add filament used if available
             if archive_data.get("actual_filament_grams"):
-                lines.append(f"Filament: {archive_data['actual_filament_grams']:.1f}g")
+                lines.append(f"{translator.t('notification.filament')}: {archive_data['actual_filament_grams']:.1f}g")
             # Add failure reason if failed
             if status == "failed" and archive_data.get("failure_reason"):
-                lines.append(f"Reason: {archive_data['failure_reason']}")
+                lines.append(f"{translator.t('notification.reason')}: {archive_data['failure_reason']}")
 
         message = "\n".join(lines)
         return title, message
 
     def _build_progress_message(
-        self, printer_name: str, filename: str, progress: int
+        self, printer_name: str, filename: str, progress: int, translator: Translator
     ) -> tuple[str, str]:
         """Build notification message for print progress milestone."""
         if filename.endswith(".gcode.3mf"):
@@ -135,40 +145,57 @@ class NotificationService:
         elif filename.endswith(".3mf"):
             filename = filename[:-4]
 
-        title = f"Print {progress}% Complete"
+        title = translator.t("notification.print_progress", progress=progress)
         message = f"{printer_name}: {filename}"
         return title, message
 
-    def _build_printer_offline_message(self, printer_name: str) -> tuple[str, str]:
+    def _build_printer_offline_message(self, printer_name: str, translator: Translator) -> tuple[str, str]:
         """Build notification message for printer offline event."""
-        title = "Printer Offline"
-        message = f"{printer_name} has disconnected"
+        title = translator.t("notification.printer_offline")
+        message = translator.t("notification.printer_disconnected", printer=printer_name)
         return title, message
 
     def _build_printer_error_message(
-        self, printer_name: str, error_type: str, error_detail: str | None = None
+        self, printer_name: str, error_type: str, translator: Translator, error_detail: str | None = None
     ) -> tuple[str, str]:
         """Build notification message for printer error event."""
-        title = f"Printer Error: {error_type}"
+        title = translator.t("notification.printer_error", error_type=error_type)
         message = f"{printer_name}"
         if error_detail:
             message += f"\n{error_detail}"
         return title, message
 
     def _build_filament_low_message(
-        self, printer_name: str, slot: int, remaining_percent: int
+        self, printer_name: str, slot: int, remaining_percent: int, translator: Translator
     ) -> tuple[str, str]:
         """Build notification message for low filament event."""
-        title = "Filament Low"
-        message = f"{printer_name}: Slot {slot} at {remaining_percent}%"
+        title = translator.t("notification.filament_low")
+        message = translator.t("notification.slot_at_percent", printer=printer_name, slot=slot, percent=remaining_percent)
+        return title, message
+
+    def _build_maintenance_due_message(
+        self, printer_name: str, maintenance_items: list[dict], translator: Translator
+    ) -> tuple[str, str]:
+        """Build notification message for maintenance due event."""
+        title = translator.t("notification.maintenance_due")
+        lines = [f"{printer_name}:"]
+        for item in maintenance_items:
+            status = translator.t("notification.overdue") if item.get("is_due") else translator.t("notification.soon")
+            lines.append(f"• {item['name']} ({status})")
+        message = "\n".join(lines)
         return title, message
 
     async def send_test_notification(
-        self, provider_type: str, config: dict[str, Any]
+        self, provider_type: str, config: dict[str, Any], db: AsyncSession | None = None
     ) -> tuple[bool, str]:
         """Send a test notification to verify configuration."""
-        title = "BambuTrack Test"
-        message = "This is a test notification from BambuTrack. If you see this, notifications are working correctly!"
+        lang = "en"
+        if db:
+            lang = await self._get_notification_language(db)
+        translator = Translator(lang)
+
+        title = translator.t("notification.test_title")
+        message = translator.t("notification.test_message")
 
         try:
             if provider_type == "callmebot":
@@ -434,8 +461,10 @@ class NotificationService:
             logger.info(f"No notification providers configured for print_start event on printer {printer_id}")
             return
 
+        lang = await self._get_notification_language(db)
+        translator = Translator(lang)
         logger.info(f"Found {len(providers)} providers for print_start: {[p.name for p in providers]}")
-        title, message = self._build_print_start_message(printer_name, data)
+        title, message = self._build_print_start_message(printer_name, data, translator)
         await self._send_to_providers(providers, title, message, db)
 
     async def on_print_complete(
@@ -466,8 +495,10 @@ class NotificationService:
             logger.info(f"No notification providers configured for {event_field} event on printer {printer_id}")
             return
 
+        lang = await self._get_notification_language(db)
+        translator = Translator(lang)
         logger.info(f"Found {len(providers)} providers for {event_field}: {[p.name for p in providers]}")
-        title, message = self._build_print_complete_message(printer_name, status, data, archive_data)
+        title, message = self._build_print_complete_message(printer_name, status, data, translator, archive_data)
         await self._send_to_providers(providers, title, message, db)
 
     async def on_print_progress(
@@ -483,7 +514,9 @@ class NotificationService:
         if not providers:
             return
 
-        title, message = self._build_progress_message(printer_name, filename, progress)
+        lang = await self._get_notification_language(db)
+        translator = Translator(lang)
+        title, message = self._build_progress_message(printer_name, filename, progress, translator)
         await self._send_to_providers(providers, title, message, db)
 
     async def on_printer_offline(
@@ -494,7 +527,9 @@ class NotificationService:
         if not providers:
             return
 
-        title, message = self._build_printer_offline_message(printer_name)
+        lang = await self._get_notification_language(db)
+        translator = Translator(lang)
+        title, message = self._build_printer_offline_message(printer_name, translator)
         await self._send_to_providers(providers, title, message, db)
 
     async def on_printer_error(
@@ -510,7 +545,9 @@ class NotificationService:
         if not providers:
             return
 
-        title, message = self._build_printer_error_message(printer_name, error_type, error_detail)
+        lang = await self._get_notification_language(db)
+        translator = Translator(lang)
+        title, message = self._build_printer_error_message(printer_name, error_type, translator, error_detail)
         await self._send_to_providers(providers, title, message, db)
 
     async def on_filament_low(
@@ -526,7 +563,31 @@ class NotificationService:
         if not providers:
             return
 
-        title, message = self._build_filament_low_message(printer_name, slot, remaining_percent)
+        lang = await self._get_notification_language(db)
+        translator = Translator(lang)
+        title, message = self._build_filament_low_message(printer_name, slot, remaining_percent, translator)
+        await self._send_to_providers(providers, title, message, db)
+
+    async def on_maintenance_due(
+        self,
+        printer_id: int,
+        printer_name: str,
+        maintenance_items: list[dict],
+        db: AsyncSession,
+    ):
+        """Handle maintenance due event - sends notification when maintenance is due or warning."""
+        if not maintenance_items:
+            return
+
+        providers = await self._get_providers_for_event(db, "on_maintenance_due", printer_id)
+        if not providers:
+            logger.info(f"No notification providers configured for maintenance_due event on printer {printer_id}")
+            return
+
+        lang = await self._get_notification_language(db)
+        translator = Translator(lang)
+        logger.info(f"Found {len(providers)} providers for maintenance_due: {[p.name for p in providers]}")
+        title, message = self._build_maintenance_due_message(printer_name, maintenance_items, translator)
         await self._send_to_providers(providers, title, message, db)
 
 

+ 12 - 0
backend/app/services/printer_manager.py

@@ -18,6 +18,7 @@ class PrinterManager:
         self._on_print_start: Callable[[int, dict], None] | None = None
         self._on_print_complete: Callable[[int, dict], None] | None = None
         self._on_status_change: Callable[[int, PrinterState], None] | None = None
+        self._on_ams_change: Callable[[int, list], None] | None = None
         self._loop: asyncio.AbstractEventLoop | None = None
 
     def set_event_loop(self, loop: asyncio.AbstractEventLoop):
@@ -36,6 +37,10 @@ class PrinterManager:
         """Set callback for status change events."""
         self._on_status_change = callback
 
+    def set_ams_change_callback(self, callback: Callable[[int, list], None]):
+        """Set callback for AMS data change events."""
+        self._on_ams_change = callback
+
     def _schedule_async(self, coro):
         """Schedule an async coroutine from a sync context."""
         if self._loop and self._loop.is_running():
@@ -66,6 +71,12 @@ class PrinterManager:
                     self._on_print_complete(printer_id, data)
                 )
 
+        def on_ams_change(ams_data: list):
+            if self._on_ams_change:
+                self._schedule_async(
+                    self._on_ams_change(printer_id, ams_data)
+                )
+
         client = BambuMQTTClient(
             ip_address=printer.ip_address,
             serial_number=printer.serial_number,
@@ -73,6 +84,7 @@ class PrinterManager:
             on_state_change=on_state_change,
             on_print_start=on_print_start,
             on_print_complete=on_print_complete,
+            on_ams_change=on_ams_change,
         )
 
         client.connect()

+ 712 - 0
backend/app/services/spoolman.py

@@ -0,0 +1,712 @@
+"""Spoolman integration service for syncing AMS filament data."""
+
+import logging
+from dataclasses import dataclass
+from datetime import datetime, timezone
+
+import httpx
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class SpoolmanSpool:
+    """Represents a spool in Spoolman."""
+
+    id: int
+    filament_id: int | None
+    remaining_weight: float | None
+    used_weight: float
+    first_used: str | None
+    last_used: str | None
+    location: str | None
+    lot_nr: str | None
+    comment: str | None
+    extra: dict | None  # Contains tag_uid in extra.tag
+
+
+@dataclass
+class SpoolmanFilament:
+    """Represents a filament type in Spoolman."""
+
+    id: int
+    name: str
+    vendor_id: int | None
+    material: str | None
+    color_hex: str | None
+    weight: float | None  # Net weight in grams
+
+
+@dataclass
+class AMSTray:
+    """Represents an AMS tray with filament data from Bambu printer."""
+
+    ams_id: int  # 0-3 for regular AMS, 128-135 for external spool
+    tray_id: int  # 0-3
+    tray_type: str  # PLA, PETG, ABS, etc.
+    tray_sub_brands: str  # Full name like "PLA Basic", "PETG HF"
+    tray_color: str  # Hex color like "FEC600FF"
+    remain: int  # Remaining percentage (0-100)
+    tag_uid: str  # RFID tag UID
+    tray_uuid: str  # Spool UUID
+    tray_weight: int  # Spool weight in grams (usually 1000)
+
+
+class SpoolmanClient:
+    """Client for interacting with Spoolman API."""
+
+    def __init__(self, base_url: str):
+        """Initialize the Spoolman client.
+
+        Args:
+            base_url: The base URL of the Spoolman server (e.g., http://localhost:7912)
+        """
+        self.base_url = base_url.rstrip("/")
+        self.api_url = f"{self.base_url}/api/v1"
+        self._client: httpx.AsyncClient | None = None
+        self._connected = False
+
+    async def _get_client(self) -> httpx.AsyncClient:
+        """Get or create the HTTP client."""
+        if self._client is None:
+            self._client = httpx.AsyncClient(timeout=10.0)
+        return self._client
+
+    async def close(self):
+        """Close the HTTP client."""
+        if self._client:
+            await self._client.aclose()
+            self._client = None
+
+    async def health_check(self) -> bool:
+        """Check if Spoolman server is reachable.
+
+        Returns:
+            True if server is healthy, False otherwise.
+        """
+        try:
+            client = await self._get_client()
+            response = await client.get(f"{self.api_url}/health")
+            self._connected = response.status_code == 200
+            return self._connected
+        except Exception as e:
+            logger.warning(f"Spoolman health check failed: {e}")
+            self._connected = False
+            return False
+
+    @property
+    def is_connected(self) -> bool:
+        """Check if client is connected to Spoolman."""
+        return self._connected
+
+    async def get_spools(self) -> list[dict]:
+        """Get all spools from Spoolman.
+
+        Returns:
+            List of spool dictionaries.
+        """
+        try:
+            client = await self._get_client()
+            response = await client.get(f"{self.api_url}/spool")
+            response.raise_for_status()
+            return response.json()
+        except Exception as e:
+            logger.error(f"Failed to get spools from Spoolman: {e}")
+            return []
+
+    async def get_filaments(self) -> list[dict]:
+        """Get all internal filaments from Spoolman.
+
+        Returns:
+            List of filament dictionaries.
+        """
+        try:
+            client = await self._get_client()
+            response = await client.get(f"{self.api_url}/filament")
+            response.raise_for_status()
+            return response.json()
+        except Exception as e:
+            logger.error(f"Failed to get filaments from Spoolman: {e}")
+            return []
+
+    async def get_external_filaments(self) -> list[dict]:
+        """Get external/library filaments from Spoolman.
+
+        Returns:
+            List of external filament dictionaries.
+        """
+        try:
+            client = await self._get_client()
+            response = await client.get(f"{self.api_url}/external/filament")
+            response.raise_for_status()
+            return response.json()
+        except Exception as e:
+            logger.error(f"Failed to get external filaments from Spoolman: {e}")
+            return []
+
+    async def get_vendors(self) -> list[dict]:
+        """Get all vendors from Spoolman.
+
+        Returns:
+            List of vendor dictionaries.
+        """
+        try:
+            client = await self._get_client()
+            response = await client.get(f"{self.api_url}/vendor")
+            response.raise_for_status()
+            return response.json()
+        except Exception as e:
+            logger.error(f"Failed to get vendors from Spoolman: {e}")
+            return []
+
+    async def create_vendor(self, name: str) -> dict | None:
+        """Create a new vendor in Spoolman.
+
+        Args:
+            name: Vendor name (e.g., "Bambu Lab")
+
+        Returns:
+            Created vendor dictionary or None on failure.
+        """
+        try:
+            client = await self._get_client()
+            response = await client.post(
+                f"{self.api_url}/vendor", json={"name": name}
+            )
+            response.raise_for_status()
+            return response.json()
+        except Exception as e:
+            logger.error(f"Failed to create vendor in Spoolman: {e}")
+            return None
+
+    def _get_material_density(self, material: str | None) -> float:
+        """Get typical density for a filament material type.
+
+        Args:
+            material: Material type (PLA, PETG, ABS, etc.)
+
+        Returns:
+            Density in g/cm³
+        """
+        # Typical densities for common filament materials
+        densities = {
+            "PLA": 1.24,
+            "PLA-CF": 1.29,
+            "PLA-S": 1.24,
+            "PETG": 1.27,
+            "ABS": 1.04,
+            "ASA": 1.07,
+            "TPU": 1.21,
+            "PA": 1.14,  # Nylon
+            "PA-CF": 1.20,
+            "PC": 1.20,
+            "PVA": 1.23,
+            "HIPS": 1.04,
+            "PP": 0.90,
+            "PET": 1.38,
+        }
+        if material:
+            # Try exact match first, then uppercase
+            mat_upper = material.upper()
+            for key, density in densities.items():
+                if key.upper() == mat_upper or mat_upper.startswith(key.upper()):
+                    return density
+        return 1.24  # Default to PLA density
+
+    async def create_filament(
+        self,
+        name: str,
+        vendor_id: int | None = None,
+        material: str | None = None,
+        color_hex: str | None = None,
+        weight: float | None = None,
+        diameter: float = 1.75,
+        density: float | None = None,
+    ) -> dict | None:
+        """Create a new filament in Spoolman.
+
+        Args:
+            name: Filament name
+            vendor_id: Vendor ID
+            material: Material type (PLA, PETG, etc.)
+            color_hex: Color in hex format (without #)
+            weight: Net weight in grams
+            diameter: Filament diameter in mm (default 1.75)
+            density: Filament density in g/cm³ (auto-calculated if not provided)
+
+        Returns:
+            Created filament dictionary or None on failure.
+        """
+        # Validate required fields
+        if not name or not name.strip():
+            logger.error("Cannot create filament: name is required")
+            return None
+
+        try:
+            # Calculate density from material if not provided
+            if density is None:
+                density = self._get_material_density(material)
+
+            data = {
+                "name": name.strip(),
+                "diameter": diameter,
+                "density": density,
+            }
+            if vendor_id:
+                data["vendor_id"] = vendor_id
+            if material:
+                data["material"] = material
+            if color_hex:
+                # Strip alpha channel if present (RRGGBBAA -> RRGGBB)
+                color_hex = color_hex[:6] if len(color_hex) >= 6 else color_hex
+                data["color_hex"] = color_hex
+            if weight:
+                data["weight"] = weight
+
+            logger.debug(f"Creating filament in Spoolman: {data}")
+            client = await self._get_client()
+            response = await client.post(f"{self.api_url}/filament", json=data)
+            response.raise_for_status()
+            return response.json()
+        except httpx.HTTPStatusError as e:
+            logger.error(f"Failed to create filament in Spoolman: {e}, response: {e.response.text}")
+            return None
+        except Exception as e:
+            logger.error(f"Failed to create filament in Spoolman: {e}")
+            return None
+
+    async def create_spool(
+        self,
+        filament_id: int,
+        remaining_weight: float | None = None,
+        location: str | None = None,
+        lot_nr: str | None = None,
+        comment: str | None = None,
+        extra: dict | None = None,
+    ) -> dict | None:
+        """Create a new spool in Spoolman.
+
+        Args:
+            filament_id: ID of the filament type
+            remaining_weight: Remaining weight in grams
+            location: Physical location description
+            lot_nr: Lot/batch number
+            comment: Optional comment
+            extra: Extra fields (e.g., {"tag": "RFID_TAG_UID"})
+
+        Returns:
+            Created spool dictionary or None on failure.
+        """
+        try:
+            data = {"filament_id": filament_id}
+            if remaining_weight is not None:
+                data["remaining_weight"] = remaining_weight
+            if location:
+                data["location"] = location
+            if lot_nr:
+                data["lot_nr"] = lot_nr
+            if comment:
+                data["comment"] = comment
+            if extra:
+                data["extra"] = extra
+
+            logger.debug(f"Creating spool in Spoolman: {data}")
+            client = await self._get_client()
+            response = await client.post(f"{self.api_url}/spool", json=data)
+            response.raise_for_status()
+            return response.json()
+        except httpx.HTTPStatusError as e:
+            logger.error(f"Failed to create spool in Spoolman: {e}, response: {e.response.text}")
+            return None
+        except Exception as e:
+            logger.error(f"Failed to create spool in Spoolman: {e}")
+            return None
+
+    async def update_spool(
+        self,
+        spool_id: int,
+        remaining_weight: float | None = None,
+        location: str | None = None,
+        extra: dict | None = None,
+    ) -> dict | None:
+        """Update an existing spool in Spoolman.
+
+        Args:
+            spool_id: ID of the spool to update
+            remaining_weight: New remaining weight in grams
+            location: New location
+            extra: Extra fields to update
+
+        Returns:
+            Updated spool dictionary or None on failure.
+        """
+        try:
+            data = {}
+            if remaining_weight is not None:
+                data["remaining_weight"] = remaining_weight
+            if location:
+                data["location"] = location
+            if extra:
+                data["extra"] = extra
+
+            # Always update last_used
+            data["last_used"] = datetime.now(timezone.utc).isoformat()
+
+            client = await self._get_client()
+            response = await client.patch(
+                f"{self.api_url}/spool/{spool_id}", json=data
+            )
+            response.raise_for_status()
+            return response.json()
+        except Exception as e:
+            logger.error(f"Failed to update spool in Spoolman: {e}")
+            return None
+
+    async def use_spool(self, spool_id: int, used_weight: float) -> dict | None:
+        """Record filament usage for a spool.
+
+        Args:
+            spool_id: ID of the spool
+            used_weight: Amount of filament used in grams
+
+        Returns:
+            Updated spool dictionary or None on failure.
+        """
+        try:
+            client = await self._get_client()
+            response = await client.put(
+                f"{self.api_url}/spool/{spool_id}/use",
+                json={"use_weight": used_weight},
+            )
+            response.raise_for_status()
+            return response.json()
+        except Exception as e:
+            logger.error(f"Failed to record spool usage in Spoolman: {e}")
+            return None
+
+    async def find_spool_by_tag(self, tag_uid: str) -> dict | None:
+        """Find a spool by its RFID tag UID.
+
+        Args:
+            tag_uid: The RFID tag UID to search for
+
+        Returns:
+            Spool dictionary or None if not found.
+        """
+        spools = await self.get_spools()
+        # Normalize tag_uid for comparison (uppercase, strip quotes)
+        search_tag = tag_uid.strip('"').upper()
+
+        for spool in spools:
+            extra = spool.get("extra", {})
+            if extra:
+                stored_tag = extra.get("tag", "")
+                # Normalize stored tag (strip quotes, uppercase)
+                if stored_tag:
+                    normalized_tag = stored_tag.strip('"').upper()
+                    if normalized_tag == search_tag:
+                        logger.debug(f"Found spool {spool['id']} matching tag {tag_uid}")
+                        return spool
+        return None
+
+    async def ensure_bambu_vendor(self) -> int | None:
+        """Ensure Bambu Lab vendor exists and return its ID.
+
+        Returns:
+            Vendor ID or None on failure.
+        """
+        vendors = await self.get_vendors()
+        for vendor in vendors:
+            if vendor.get("name", "").lower() == "bambu lab":
+                return vendor["id"]
+
+        # Create Bambu Lab vendor if not exists
+        vendor = await self.create_vendor("Bambu Lab")
+        return vendor["id"] if vendor else None
+
+    def parse_ams_tray(self, ams_id: int, tray_data: dict) -> AMSTray | None:
+        """Parse AMS tray data into AMSTray object.
+
+        Args:
+            ams_id: The AMS unit ID (0-3 for regular, 128-135 for external)
+            tray_data: Raw tray data from MQTT
+
+        Returns:
+            AMSTray object or None if tray is empty or invalid.
+        """
+        # Skip empty trays - check for valid tray_type
+        tray_type = tray_data.get("tray_type", "")
+        if not tray_type or tray_type.strip() == "":
+            return None
+
+        # Also need valid color to create filament (000000FF = unset/empty)
+        tray_color = tray_data.get("tray_color", "")
+        if not tray_color or tray_color in ("", "000000FF", "00000000"):
+            logger.debug(f"Skipping tray with invalid color: {tray_color}")
+            return None
+
+        # Get sub_brands, falling back to tray_type
+        tray_sub_brands = tray_data.get("tray_sub_brands", "")
+        if not tray_sub_brands or tray_sub_brands.strip() == "":
+            tray_sub_brands = tray_type
+
+        # Get tag_uid and tray_uuid, filtering out empty/invalid values
+        tag_uid = tray_data.get("tag_uid", "")
+        if tag_uid in ("", "0000000000000000"):
+            tag_uid = ""
+        tray_uuid = tray_data.get("tray_uuid", "")
+        if tray_uuid in ("", "00000000000000000000000000000000"):
+            tray_uuid = ""
+
+        # Get remaining percentage, ensure non-negative
+        remain = max(0, int(tray_data.get("remain", 0)))
+
+        return AMSTray(
+            ams_id=ams_id,
+            tray_id=int(tray_data.get("id", 0)),
+            tray_type=tray_type.strip(),
+            tray_sub_brands=tray_sub_brands.strip(),
+            tray_color=tray_color,
+            remain=remain,
+            tag_uid=tag_uid,
+            tray_uuid=tray_uuid,
+            tray_weight=int(tray_data.get("tray_weight", 1000)),
+        )
+
+    def convert_ams_slot_to_location(self, ams_id: int, tray_id: int) -> str:
+        """Convert AMS ID and tray ID to human-readable location.
+
+        Args:
+            ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for external)
+            tray_id: Tray ID within the AMS (0-3)
+
+        Returns:
+            Location string like "AMS A1", "AMS B2", "External"
+        """
+        if ams_id >= 128:
+            return "External Spool"
+
+        ams_letter = chr(ord("A") + ams_id)
+        return f"AMS {ams_letter}{tray_id + 1}"
+
+    def is_bambu_lab_spool(self, tray_uuid: str) -> bool:
+        """Check if a tray has a valid Bambu Lab spool UUID.
+
+        Bambu Lab spools have a tray_uuid which is a 32-character hex string.
+        This UUID is consistent across all printer models (unlike tag_uid which
+        varies between X1C/H2D readers).
+
+        Non-Bambu Lab spools (SpoolEase, third-party) won't have a valid tray_uuid.
+
+        Args:
+            tray_uuid: The tray UUID to check
+
+        Returns:
+            True if the spool has a valid Bambu Lab UUID, False otherwise.
+        """
+        if not tray_uuid:
+            return False
+        # Bambu Lab tray_uuid is always 32 hex characters
+        uuid = tray_uuid.strip()
+        if len(uuid) != 32:
+            return False
+        # Verify it's all hex characters and not empty/zero
+        if uuid == "00000000000000000000000000000000":
+            return False
+        try:
+            int(uuid, 16)
+            return True
+        except ValueError:
+            return False
+
+    def calculate_remaining_weight(
+        self, remain_percent: int, spool_weight: int
+    ) -> float:
+        """Calculate remaining weight from percentage.
+
+        Args:
+            remain_percent: Remaining percentage (0-100)
+            spool_weight: Total spool weight in grams
+
+        Returns:
+            Remaining weight in grams.
+        """
+        return (remain_percent / 100.0) * spool_weight
+
+    async def sync_ams_tray(
+        self, tray: AMSTray, printer_name: str
+    ) -> dict | None:
+        """Sync a single AMS tray to Spoolman.
+
+        Only syncs trays with valid Bambu Lab tray_uuid (32 hex characters).
+        Non-Bambu Lab spools (SpoolEase/third-party) are skipped.
+
+        Uses tray_uuid for matching, as it's consistent across all printer models
+        (unlike tag_uid which varies between X1C/H2D readers).
+
+        Args:
+            tray: The AMSTray to sync
+            printer_name: Name of the printer for location
+
+        Returns:
+            Synced spool dictionary or None if skipped or failed.
+        """
+        logger.debug(
+            f"Processing {printer_name} AMS {tray.ams_id} tray {tray.tray_id}: "
+            f"type={tray.tray_type}, uuid={tray.tray_uuid[:16] if tray.tray_uuid else 'none'}..."
+        )
+
+        # Only sync trays with valid Bambu Lab tray_uuid
+        if not self.is_bambu_lab_spool(tray.tray_uuid):
+            if tray.tray_uuid or tray.tag_uid:
+                logger.info(
+                    f"Skipping non-Bambu Lab spool: {printer_name} AMS {tray.ams_id} tray {tray.tray_id} "
+                    f"(tray_uuid={tray.tray_uuid}, tag_uid={tray.tag_uid})"
+                )
+            else:
+                logger.debug(
+                    f"Skipping tray without RFID tag: AMS {tray.ams_id} tray {tray.tray_id}"
+                )
+            return None
+
+        # Calculate remaining weight
+        remaining = self.calculate_remaining_weight(tray.remain, tray.tray_weight)
+        location = f"{printer_name} - {self.convert_ams_slot_to_location(tray.ams_id, tray.tray_id)}"
+
+        # Find existing spool by tray_uuid (stored as "tag" in Spoolman)
+        existing = await self.find_spool_by_tag(tray.tray_uuid)
+        if existing:
+            # Update existing spool
+            logger.info(
+                f"Updating existing spool {existing['id']} for tray_uuid {tray.tray_uuid}"
+            )
+            return await self.update_spool(
+                spool_id=existing["id"],
+                remaining_weight=remaining,
+                location=location,
+            )
+
+        # Spool not found - auto-create it
+        logger.info(
+            f"Creating new spool in Spoolman for {tray.tray_sub_brands} "
+            f"(tray_uuid: {tray.tray_uuid[:16]}...)"
+        )
+
+        # First find or create the filament type
+        filament = await self._find_or_create_filament(tray)
+        if not filament:
+            logger.error(f"Failed to find or create filament for {tray.tray_sub_brands}")
+            return None
+
+        # Create the spool with tray_uuid stored as "tag" in extra field
+        return await self.create_spool(
+            filament_id=filament["id"],
+            remaining_weight=remaining,
+            location=location,
+            comment=f"Auto-created from {printer_name} AMS",
+            extra={"tag": tray.tray_uuid},
+        )
+
+    async def _find_or_create_filament(self, tray: AMSTray) -> dict | None:
+        """Find existing filament or create new one.
+
+        Args:
+            tray: The AMSTray containing filament info
+
+        Returns:
+            Filament dictionary or None on failure.
+        """
+        # Search internal filaments first
+        filaments = await self.get_filaments()
+        color_hex = tray.tray_color[:6]  # Strip alpha channel
+
+        for filament in filaments:
+            # Match by material and color (handle None values)
+            fil_material = filament.get("material") or ""
+            fil_color = filament.get("color_hex") or ""
+            if (
+                fil_material.upper() == tray.tray_type.upper()
+                and fil_color.upper() == color_hex.upper()
+            ):
+                return filament
+
+        # Search external filaments (Bambu library)
+        external = await self.get_external_filaments()
+        for filament in external:
+            fil_material = filament.get("material") or ""
+            fil_color = filament.get("color_hex") or ""
+            if (
+                fil_material.upper() == tray.tray_type.upper()
+                and fil_color.upper() == color_hex.upper()
+            ):
+                # Found in external library - need to create internal copy
+                return await self._create_filament_from_external(filament, tray)
+
+        # Not found - create new filament
+        vendor_id = await self.ensure_bambu_vendor()
+        return await self.create_filament(
+            name=tray.tray_sub_brands or tray.tray_type,
+            vendor_id=vendor_id,
+            material=tray.tray_type,
+            color_hex=color_hex,
+            weight=tray.tray_weight,
+        )
+
+    async def _create_filament_from_external(
+        self, external: dict, tray: AMSTray
+    ) -> dict | None:
+        """Create internal filament from external library entry.
+
+        Args:
+            external: External filament dictionary
+            tray: The AMSTray for additional info
+
+        Returns:
+            Created filament dictionary or None on failure.
+        """
+        vendor_id = await self.ensure_bambu_vendor()
+        return await self.create_filament(
+            name=external.get("name", tray.tray_sub_brands),
+            vendor_id=vendor_id,
+            material=external.get("material", tray.tray_type),
+            color_hex=external.get("color_hex", tray.tray_color[:6]),
+            weight=external.get("weight", tray.tray_weight),
+        )
+
+
+# Global client instance (initialized when settings are loaded)
+_spoolman_client: SpoolmanClient | None = None
+
+
+async def get_spoolman_client() -> SpoolmanClient | None:
+    """Get the global Spoolman client instance.
+
+    Returns:
+        SpoolmanClient instance or None if not configured.
+    """
+    return _spoolman_client
+
+
+async def init_spoolman_client(url: str) -> SpoolmanClient:
+    """Initialize the global Spoolman client.
+
+    Args:
+        url: Spoolman server URL
+
+    Returns:
+        Initialized SpoolmanClient instance.
+    """
+    global _spoolman_client
+    if _spoolman_client:
+        await _spoolman_client.close()
+
+    _spoolman_client = SpoolmanClient(url)
+    return _spoolman_client
+
+
+async def close_spoolman_client():
+    """Close the global Spoolman client."""
+    global _spoolman_client
+    if _spoolman_client:
+        await _spoolman_client.close()
+        _spoolman_client = None

BIN
docs/screenshots/maintenance.png


+ 160 - 1
frontend/package-lock.json

@@ -22,10 +22,14 @@
         "@tiptap/starter-kit": "^3.11.1",
         "@types/three": "^0.181.0",
         "gcode-preview": "^2.18.0",
+        "i18next": "^25.6.3",
+        "i18next-browser-languagedetector": "^8.2.0",
+        "i18next-http-backend": "^3.0.2",
         "jszip": "^3.10.1",
         "lucide-react": "^0.555.0",
         "react": "^19.2.0",
         "react-dom": "^19.2.0",
+        "react-i18next": "^16.3.5",
         "react-router-dom": "^7.9.6",
         "recharts": "^3.5.1",
         "three": "^0.181.2"
@@ -297,6 +301,15 @@
         "@babel/core": "^7.0.0-0"
       }
     },
+    "node_modules/@babel/runtime": {
+      "version": "7.28.4",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
+      "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
     "node_modules/@babel/template": {
       "version": "7.27.2",
       "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
@@ -3072,6 +3085,15 @@
       "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
       "license": "MIT"
     },
+    "node_modules/cross-fetch": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
+      "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==",
+      "license": "MIT",
+      "dependencies": {
+        "node-fetch": "^2.6.12"
+      }
+    },
     "node_modules/cross-spawn": {
       "version": "7.0.6",
       "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3770,6 +3792,65 @@
         "hermes-estree": "0.25.1"
       }
     },
+    "node_modules/html-parse-stringify": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
+      "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
+      "license": "MIT",
+      "dependencies": {
+        "void-elements": "3.1.0"
+      }
+    },
+    "node_modules/i18next": {
+      "version": "25.6.3",
+      "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.6.3.tgz",
+      "integrity": "sha512-AEQvoPDljhp67a1+NsnG/Wb1Nh6YoSvtrmeEd24sfGn3uujCtXCF3cXpr7ulhMywKNFF7p3TX1u2j7y+caLOJg==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://locize.com"
+        },
+        {
+          "type": "individual",
+          "url": "https://locize.com/i18next.html"
+        },
+        {
+          "type": "individual",
+          "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
+        }
+      ],
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "@babel/runtime": "^7.28.4"
+      },
+      "peerDependencies": {
+        "typescript": "^5"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/i18next-browser-languagedetector": {
+      "version": "8.2.0",
+      "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz",
+      "integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.23.2"
+      }
+    },
+    "node_modules/i18next-http-backend": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz",
+      "integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==",
+      "license": "MIT",
+      "dependencies": {
+        "cross-fetch": "4.0.0"
+      }
+    },
     "node_modules/ignore": {
       "version": "5.3.2",
       "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -4405,6 +4486,26 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/node-fetch": {
+      "version": "2.7.0",
+      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
+      "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
+      "license": "MIT",
+      "dependencies": {
+        "whatwg-url": "^5.0.0"
+      },
+      "engines": {
+        "node": "4.x || >=6.0.0"
+      },
+      "peerDependencies": {
+        "encoding": "^0.1.0"
+      },
+      "peerDependenciesMeta": {
+        "encoding": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/node-releases": {
       "version": "2.0.27",
       "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@@ -4831,6 +4932,33 @@
         "react": "^19.2.0"
       }
     },
+    "node_modules/react-i18next": {
+      "version": "16.3.5",
+      "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.3.5.tgz",
+      "integrity": "sha512-F7Kglc+T0aE6W2rO5eCAFBEuWRpNb5IFmXOYEgztjZEuiuSLTe/xBIEG6Q3S0fbl8GXMNo+Q7gF8bpokFNWJww==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.27.6",
+        "html-parse-stringify": "^3.0.1",
+        "use-sync-external-store": "^1.6.0"
+      },
+      "peerDependencies": {
+        "i18next": ">= 25.6.2",
+        "react": ">= 16.8.0",
+        "typescript": "^5"
+      },
+      "peerDependenciesMeta": {
+        "react-dom": {
+          "optional": true
+        },
+        "react-native": {
+          "optional": true
+        },
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/react-is": {
       "version": "19.2.0",
       "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz",
@@ -5187,6 +5315,12 @@
         "url": "https://github.com/sponsors/SuperchupuDev"
       }
     },
+    "node_modules/tr46": {
+      "version": "0.0.3",
+      "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+      "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
+      "license": "MIT"
+    },
     "node_modules/ts-api-utils": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
@@ -5223,7 +5357,7 @@
       "version": "5.9.3",
       "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
       "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
-      "dev": true,
+      "devOptional": true,
       "license": "Apache-2.0",
       "peer": true,
       "bin": {
@@ -5425,12 +5559,37 @@
         }
       }
     },
+    "node_modules/void-elements": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
+      "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/w3c-keyname": {
       "version": "2.2.8",
       "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
       "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
       "license": "MIT"
     },
+    "node_modules/webidl-conversions": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+      "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
+      "license": "BSD-2-Clause"
+    },
+    "node_modules/whatwg-url": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+      "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+      "license": "MIT",
+      "dependencies": {
+        "tr46": "~0.0.3",
+        "webidl-conversions": "^3.0.0"
+      }
+    },
     "node_modules/which": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

+ 4 - 0
frontend/package.json

@@ -24,10 +24,14 @@
     "@tiptap/starter-kit": "^3.11.1",
     "@types/three": "^0.181.0",
     "gcode-preview": "^2.18.0",
+    "i18next": "^25.6.3",
+    "i18next-browser-languagedetector": "^8.2.0",
+    "i18next-http-backend": "^3.0.2",
     "jszip": "^3.10.1",
     "lucide-react": "^0.555.0",
     "react": "^19.2.0",
     "react-dom": "^19.2.0",
+    "react-i18next": "^16.3.5",
     "react-router-dom": "^7.9.6",
     "recharts": "^3.5.1",
     "three": "^0.181.2"

+ 2 - 0
frontend/src/App.tsx

@@ -7,6 +7,7 @@ import { QueuePage } from './pages/QueuePage';
 import { StatsPage } from './pages/StatsPage';
 import { SettingsPage } from './pages/SettingsPage';
 import { ProfilesPage } from './pages/ProfilesPage';
+import { MaintenancePage } from './pages/MaintenancePage';
 import { useWebSocket } from './hooks/useWebSocket';
 import { ThemeProvider } from './contexts/ThemeContext';
 import { ToastProvider } from './contexts/ToastContext';
@@ -39,6 +40,7 @@ function App() {
                   <Route path="queue" element={<QueuePage />} />
                   <Route path="stats" element={<StatsPage />} />
                   <Route path="profiles" element={<ProfilesPage />} />
+                  <Route path="maintenance" element={<MaintenancePage />} />
                   <Route path="settings" element={<SettingsPage />} />
                 </Route>
               </Routes>

+ 198 - 0
frontend/src/api/client.ts

@@ -90,6 +90,7 @@ export interface Archive {
   content_hash: string | null;
   thumbnail_path: string | null;
   timelapse_path: string | null;
+  source_3mf_path: string | null;
   duplicates: ArchiveDuplicate[] | null;
   duplicate_count: number;
   print_name: string | null;
@@ -152,6 +153,8 @@ export interface AppSettings {
   currency: string;
   energy_cost_per_kwh: number;
   energy_tracking_mode: 'print' | 'total';
+  check_updates: boolean;
+  notification_language: string;
 }
 
 export type AppSettingsUpdate = Partial<AppSettings>;
@@ -374,6 +377,7 @@ export interface NotificationProvider {
   on_printer_offline: boolean;
   on_printer_error: boolean;
   on_filament_low: boolean;
+  on_maintenance_due: boolean;
   // Quiet hours
   quiet_hours_enabled: boolean;
   quiet_hours_start: string | null;
@@ -404,6 +408,7 @@ export interface NotificationProviderCreate {
   on_printer_offline?: boolean;
   on_printer_error?: boolean;
   on_filament_low?: boolean;
+  on_maintenance_due?: boolean;
   // Quiet hours
   quiet_hours_enabled?: boolean;
   quiet_hours_start?: string | null;
@@ -427,6 +432,7 @@ export interface NotificationProviderUpdate {
   on_printer_offline?: boolean;
   on_printer_error?: boolean;
   on_filament_low?: boolean;
+  on_maintenance_due?: boolean;
   // Quiet hours
   quiet_hours_enabled?: boolean;
   quiet_hours_start?: string | null;
@@ -478,6 +484,107 @@ export interface EmailConfig {
   use_tls?: boolean;
 }
 
+// Spoolman types
+export interface SpoolmanStatus {
+  enabled: boolean;
+  connected: boolean;
+  url: string | null;
+}
+
+export interface SpoolmanSyncResult {
+  success: boolean;
+  synced_count: number;
+  errors: string[];
+}
+
+// Update types
+export interface VersionInfo {
+  version: string;
+  repo: string;
+}
+
+export interface UpdateCheckResult {
+  update_available: boolean;
+  current_version: string;
+  latest_version: string | null;
+  release_name?: string;
+  release_notes?: string;
+  release_url?: string;
+  published_at?: string;
+  error?: string;
+  message?: string;
+}
+
+export interface UpdateStatus {
+  status: 'idle' | 'checking' | 'downloading' | 'installing' | 'complete' | 'error';
+  progress: number;
+  message: string;
+  error: string | null;
+}
+
+// Maintenance types
+export interface MaintenanceType {
+  id: number;
+  name: string;
+  description: string | null;
+  default_interval_hours: number;
+  icon: string | null;
+  is_system: boolean;
+  created_at: string;
+}
+
+export interface MaintenanceTypeCreate {
+  name: string;
+  description?: string | null;
+  default_interval_hours?: number;
+  icon?: string | null;
+}
+
+export interface MaintenanceStatus {
+  id: number;
+  printer_id: number;
+  printer_name: string;
+  maintenance_type_id: number;
+  maintenance_type_name: string;
+  maintenance_type_icon: string | null;
+  enabled: boolean;
+  interval_hours: number;
+  current_hours: number;
+  hours_since_maintenance: number;
+  hours_until_due: number;
+  is_due: boolean;
+  is_warning: boolean;
+  last_performed_at: string | null;
+}
+
+export interface PrinterMaintenanceOverview {
+  printer_id: number;
+  printer_name: string;
+  total_print_hours: number;
+  maintenance_items: MaintenanceStatus[];
+  due_count: number;
+  warning_count: number;
+}
+
+export interface MaintenanceHistory {
+  id: number;
+  printer_maintenance_id: number;
+  performed_at: string;
+  hours_at_maintenance: number;
+  notes: string | null;
+}
+
+export interface MaintenanceSummary {
+  total_due: number;
+  total_warning: number;
+  printers_with_issues: Array<{
+    printer_id: number;
+    printer_name: string;
+    due_count: number;
+    warning_count: number;
+  }>;
+}
+
 // API functions
 export const api = {
   // Printers
@@ -626,6 +733,29 @@ export const api = {
     request<{ status: string; photos: string[] | null }>(`/archives/${archiveId}/photos/${encodeURIComponent(filename)}`, {
       method: 'DELETE',
     }),
+  // Source 3MF (original slicer project file)
+  getSource3mfDownloadUrl: (archiveId: number) =>
+    `${API_BASE}/archives/${archiveId}/source`,
+  getSource3mfForSlicer: (archiveId: number, filename: string) =>
+    `${API_BASE}/archives/${archiveId}/source/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
+  uploadSource3mf: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {
+    const formData = new FormData();
+    formData.append('file', file);
+    const response = await fetch(`${API_BASE}/archives/${archiveId}/source`, {
+      method: 'POST',
+      body: formData,
+    });
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      throw new Error(error.detail || `HTTP ${response.status}`);
+    }
+    return response.json();
+  },
+  deleteSource3mf: (archiveId: number) =>
+    request<{ status: string }>(`/archives/${archiveId}/source`, {
+      method: 'DELETE',
+    }),
+
   // QR Code
   getArchiveQRCodeUrl: (archiveId: number, size = 200) =>
     `${API_BASE}/archives/${archiveId}/qrcode?size=${size}`,
@@ -846,4 +976,72 @@ export const api = {
       method: 'POST',
       body: JSON.stringify(data),
     }),
+
+  // Spoolman Integration
+  getSpoolmanStatus: () => request<SpoolmanStatus>('/spoolman/status'),
+  connectSpoolman: () =>
+    request<{ success: boolean; message: string }>('/spoolman/connect', {
+      method: 'POST',
+    }),
+  disconnectSpoolman: () =>
+    request<{ success: boolean; message: string }>('/spoolman/disconnect', {
+      method: 'POST',
+    }),
+  syncPrinterAms: (printerId: number) =>
+    request<SpoolmanSyncResult>(`/spoolman/sync/${printerId}`, {
+      method: 'POST',
+    }),
+  syncAllPrintersAms: () =>
+    request<SpoolmanSyncResult>('/spoolman/sync-all', {
+      method: 'POST',
+    }),
+  getSpoolmanSpools: () =>
+    request<{ spools: unknown[] }>('/spoolman/spools'),
+  getSpoolmanFilaments: () =>
+    request<{ filaments: unknown[] }>('/spoolman/filaments'),
+
+  // Updates
+  getVersion: () => request<VersionInfo>('/updates/version'),
+  checkForUpdates: () => request<UpdateCheckResult>('/updates/check'),
+  applyUpdate: () =>
+    request<{ success: boolean; message: string; status: UpdateStatus }>('/updates/apply', {
+      method: 'POST',
+    }),
+  getUpdateStatus: () => request<UpdateStatus>('/updates/status'),
+
+  // Maintenance
+  getMaintenanceTypes: () => request<MaintenanceType[]>('/maintenance/types'),
+  createMaintenanceType: (data: MaintenanceTypeCreate) =>
+    request<MaintenanceType>('/maintenance/types', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  updateMaintenanceType: (id: number, data: Partial<MaintenanceTypeCreate>) =>
+    request<MaintenanceType>(`/maintenance/types/${id}`, {
+      method: 'PATCH',
+      body: JSON.stringify(data),
+    }),
+  deleteMaintenanceType: (id: number) =>
+    request<{ status: string }>(`/maintenance/types/${id}`, { method: 'DELETE' }),
+  getMaintenanceOverview: () => request<PrinterMaintenanceOverview[]>('/maintenance/overview'),
+  getPrinterMaintenance: (printerId: number) =>
+    request<PrinterMaintenanceOverview>(`/maintenance/printers/${printerId}`),
+  updateMaintenanceItem: (itemId: number, data: { custom_interval_hours?: number | null; enabled?: boolean }) =>
+    request<MaintenanceStatus>(`/maintenance/items/${itemId}`, {
+      method: 'PATCH',
+      body: JSON.stringify(data),
+    }),
+  performMaintenance: (itemId: number, notes?: string) =>
+    request<MaintenanceStatus>(`/maintenance/items/${itemId}/perform`, {
+      method: 'POST',
+      body: JSON.stringify({ notes }),
+    }),
+  getMaintenanceHistory: (itemId: number) =>
+    request<MaintenanceHistory[]>(`/maintenance/items/${itemId}/history`),
+  getMaintenanceSummary: () => request<MaintenanceSummary>('/maintenance/summary'),
+  setPrinterHours: (printerId: number, totalHours: number) =>
+    request<{ printer_id: number; total_hours: number; archive_hours: number; offset_hours: number }>(
+      `/maintenance/printers/${printerId}/hours?total_hours=${totalHours}`,
+      { method: 'PATCH' }
+    ),
 };

+ 5 - 9
frontend/src/components/AddNotificationModal.tsx

@@ -4,6 +4,7 @@ import { X, Save, Loader2, Send, CheckCircle, XCircle } from 'lucide-react';
 import { api } from '../api/client';
 import type { NotificationProvider, NotificationProviderCreate, NotificationProviderUpdate, ProviderType } from '../api/client';
 import { Button } from './Button';
+import { Toggle } from './Toggle';
 
 interface AddNotificationModalProps {
   provider?: NotificationProvider | null;
@@ -350,15 +351,10 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
           <div className="space-y-2">
             <div className="flex items-center justify-between">
               <label className="text-sm text-white">Quiet Hours (Do Not Disturb)</label>
-              <label className="relative inline-flex items-center cursor-pointer">
-                <input
-                  type="checkbox"
-                  checked={quietHoursEnabled}
-                  onChange={(e) => setQuietHoursEnabled(e.target.checked)}
-                  className="sr-only peer"
-                />
-                <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
-              </label>
+              <Toggle
+                checked={quietHoursEnabled}
+                onChange={setQuietHoursEnabled}
+              />
             </div>
             {quietHoursEnabled && (
               <div className="grid grid-cols-2 gap-3">

+ 27 - 7
frontend/src/components/KProfilesView.tsx

@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React, { useState, useEffect } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import {
   Gauge,
@@ -499,11 +499,37 @@ export function KProfilesView() {
     queryFn: () => api.getKProfiles(selectedPrinter!, nozzleDiameter),
     enabled: !!selectedPrinter,
     retry: false,
+    staleTime: 0,  // Always consider data stale to ensure fresh fetch
+    refetchOnMount: 'always',  // Always refetch when component mounts
   });
 
   // Check if error is due to printer not being connected
   const isOfflineError = kprofilesError?.message?.includes('not connected');
 
+  // Auto-select first connected printer
+  useEffect(() => {
+    if (!selectedPrinter && printers && printers.length > 0) {
+      const activePrinter = printers.find((p) => p.is_active);
+      if (activePrinter) {
+        setSelectedPrinter(activePrinter.id);
+      }
+    }
+  }, [selectedPrinter, printers]);
+
+  // Refetch profiles when printer selection changes
+  useEffect(() => {
+    if (selectedPrinter) {
+      // Delay refetch to ensure query is enabled after state update
+      const timer = setTimeout(() => {
+        refetchProfiles();
+      }, 150);
+      return () => clearTimeout(timer);
+    }
+  }, [selectedPrinter, nozzleDiameter]); // eslint-disable-line react-hooks/exhaustive-deps
+
+  // Get connected printers for display
+  const connectedPrinters = printers?.filter((p) => p.is_active) || [];
+
   // Filter profiles based on search query, extruder filter, and flow type
   const filteredProfiles = React.useMemo(() => {
     if (!kprofiles?.profiles) return [];
@@ -532,12 +558,6 @@ export function KProfilesView() {
     });
   }, [kprofiles?.profiles, searchQuery, extruderFilter, flowTypeFilter]);
 
-  // Auto-select first connected printer
-  const connectedPrinters = printers?.filter((p) => p.is_active) || [];
-  if (!selectedPrinter && connectedPrinters.length > 0) {
-    setSelectedPrinter(connectedPrinters[0].id);
-  }
-
   // Check if selected printer is dual-nozzle (auto-detected from MQTT temperature data)
   const selectedPrinterData = printers?.find((p) => p.id === selectedPrinter);
   const isDualNozzle = selectedPrinterData?.nozzle_count === 2;

+ 6 - 4
frontend/src/components/KeyboardShortcutsModal.tsx

@@ -1,11 +1,12 @@
 import { useEffect } from 'react';
 import { X, Keyboard } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
 import { Card, CardContent } from './Card';
 
 interface NavItem {
   id: string;
   to: string;
-  label: string;
+  labelKey: string;
 }
 
 interface KeyboardShortcutsModalProps {
@@ -13,11 +14,11 @@ interface KeyboardShortcutsModalProps {
   navItems?: NavItem[];
 }
 
-function getShortcuts(navItems?: NavItem[]) {
+function getShortcuts(navItems: NavItem[] | undefined, t: (key: string) => string) {
   const navShortcuts = navItems
     ? navItems.map((item, index) => ({
         keys: [String(index + 1)],
-        description: `Go to ${item.label}`,
+        description: `Go to ${t(item.labelKey)}`,
       }))
     : [
         { keys: ['1'], description: 'Go to Printers' },
@@ -51,7 +52,8 @@ function KeyBadge({ children }: { children: string }) {
 }
 
 export function KeyboardShortcutsModal({ onClose, navItems }: KeyboardShortcutsModalProps) {
-  const shortcuts = getShortcuts(navItems);
+  const { t } = useTranslation();
+  const shortcuts = getShortcuts(navItems, t);
 
   // Close on Escape key
   useEffect(() => {

+ 66 - 19
frontend/src/components/Layout.tsx

@@ -1,23 +1,27 @@
 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, type LucideIcon } from 'lucide-react';
+import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, type LucideIcon } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
+import { useQuery } from '@tanstack/react-query';
+import { api } from '../api/client';
 
 interface NavItem {
   id: string;
   to: string;
   icon: LucideIcon;
-  label: string;
+  labelKey: string; // Translation key
 }
 
 export const defaultNavItems: NavItem[] = [
-  { id: 'printers', to: '/', icon: Printer, label: 'Printers' },
-  { id: 'archives', to: '/archives', icon: Archive, label: 'Archives' },
-  { id: 'queue', to: '/queue', icon: Calendar, label: 'Queue' },
-  { id: 'stats', to: '/stats', icon: BarChart3, label: 'Statistics' },
-  { id: 'profiles', to: '/profiles', icon: Cloud, label: 'Profiles' },
-  { id: 'settings', to: '/settings', icon: Settings, label: 'Settings' },
+  { id: 'printers', to: '/', icon: Printer, labelKey: 'nav.printers' },
+  { id: 'archives', to: '/archives', icon: Archive, labelKey: 'nav.archives' },
+  { id: 'queue', to: '/queue', icon: Calendar, labelKey: 'nav.queue' },
+  { id: 'stats', to: '/stats', icon: BarChart3, labelKey: 'nav.stats' },
+  { id: 'profiles', to: '/profiles', icon: Cloud, labelKey: 'nav.profiles' },
+  { id: 'maintenance', to: '/maintenance', icon: Wrench, labelKey: 'nav.maintenance' },
+  { id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' },
 ];
 
 // Get ordered nav items from localStorage
@@ -66,6 +70,7 @@ export function Layout() {
   const navigate = useNavigate();
   const location = useLocation();
   const { theme, toggleTheme } = useTheme();
+  const { t } = useTranslation();
   const [sidebarExpanded, setSidebarExpanded] = useState(() => {
     const stored = localStorage.getItem('sidebarExpanded');
     return stored !== 'false';
@@ -76,6 +81,27 @@ export function Layout() {
   const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
   const hasRedirected = useRef(false);
 
+  // Check for updates
+  const { data: versionInfo } = useQuery({
+    queryKey: ['version'],
+    queryFn: api.getVersion,
+    staleTime: Infinity,
+  });
+
+  const { data: settings } = useQuery({
+    queryKey: ['settings'],
+    queryFn: api.getSettings,
+    staleTime: 5 * 60 * 1000, // 5 minutes
+  });
+
+  const { data: updateCheck } = useQuery({
+    queryKey: ['updateCheck'],
+    queryFn: api.checkForUpdates,
+    enabled: settings?.check_updates !== false,
+    staleTime: 60 * 60 * 1000, // 1 hour
+    refetchInterval: 60 * 60 * 1000, // Check every hour
+  });
+
   // Redirect to default view on initial load
   useEffect(() => {
     if (!hasRedirected.current && location.pathname === '/') {
@@ -183,7 +209,7 @@ export function Layout() {
         {/* Navigation */}
         <nav className="flex-1 p-2">
           <ul className="space-y-2">
-            {navItems.map(({ id, to, icon: Icon, label }, index) => (
+            {navItems.map(({ id, to, icon: Icon, labelKey }, index) => (
               <li
                 key={id}
                 draggable
@@ -209,13 +235,13 @@ export function Layout() {
                         : 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
                     }`
                   }
-                  title={!sidebarExpanded ? label : undefined}
+                  title={!sidebarExpanded ? t(labelKey) : undefined}
                 >
                   {sidebarExpanded && (
                     <GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
                   )}
                   <Icon className="w-5 h-5 flex-shrink-0" />
-                  {sidebarExpanded && <span>{label}</span>}
+                  {sidebarExpanded && <span>{t(labelKey)}</span>}
                 </NavLink>
               </li>
             ))}
@@ -226,7 +252,7 @@ export function Layout() {
         <button
           onClick={() => setSidebarExpanded(!sidebarExpanded)}
           className="p-2 mx-2 mb-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white flex items-center justify-center"
-          title={sidebarExpanded ? 'Collapse sidebar' : 'Expand sidebar'}
+          title={sidebarExpanded ? t('nav.collapseSidebar') : t('nav.expandSidebar')}
         >
           {sidebarExpanded ? (
             <ChevronLeft className="w-5 h-5" />
@@ -239,28 +265,40 @@ export function Layout() {
         <div className="p-2 border-t border-bambu-dark-tertiary">
           {sidebarExpanded ? (
             <div className="flex items-center justify-between px-2">
-              <span className="text-sm text-bambu-gray">v0.1.3</span>
+              <div className="flex items-center gap-2">
+                <span className="text-sm text-bambu-gray">v{versionInfo?.version || '...'}</span>
+                {updateCheck?.update_available && (
+                  <button
+                    onClick={() => navigate('/settings')}
+                    className="flex items-center gap-1 text-xs text-bambu-green hover:text-bambu-green/80 transition-colors"
+                    title={t('nav.updateAvailable', { version: updateCheck.latest_version })}
+                  >
+                    <ArrowUpCircle className="w-4 h-4" />
+                    <span>{t('nav.update')}</span>
+                  </button>
+                )}
+              </div>
               <div className="flex items-center gap-1">
                 <a
                   href="https://github.com/maziggy/bambusy"
                   target="_blank"
                   rel="noopener noreferrer"
                   className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
-                  title="View on GitHub"
+                  title={t('nav.viewOnGithub')}
                 >
                   <Github className="w-5 h-5" />
                 </a>
                 <button
                   onClick={() => setShowShortcuts(true)}
                   className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
-                  title="Keyboard shortcuts (?)"
+                  title={t('nav.keyboardShortcuts')}
                 >
                   <Keyboard className="w-5 h-5" />
                 </button>
                 <button
                   onClick={toggleTheme}
                   className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
-                  title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
+                  title={theme === 'dark' ? t('nav.switchToLight') : t('nav.switchToDark')}
                 >
                   {theme === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
                 </button>
@@ -268,26 +306,35 @@ export function Layout() {
             </div>
           ) : (
             <div className="flex flex-col items-center gap-1">
+              {updateCheck?.update_available && (
+                <button
+                  onClick={() => navigate('/settings')}
+                  className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-green hover:text-bambu-green/80"
+                  title={t('nav.updateAvailable', { version: updateCheck.latest_version })}
+                >
+                  <ArrowUpCircle className="w-5 h-5" />
+                </button>
+              )}
               <a
                 href="https://github.com/maziggy/bambusy"
                 target="_blank"
                 rel="noopener noreferrer"
                 className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
-                title="View on GitHub"
+                title={t('nav.viewOnGithub')}
               >
                 <Github className="w-5 h-5" />
               </a>
               <button
                 onClick={() => setShowShortcuts(true)}
                 className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
-                title="Keyboard shortcuts (?)"
+                title={t('nav.keyboardShortcuts')}
               >
                 <Keyboard className="w-5 h-5" />
               </button>
               <button
                 onClick={toggleTheme}
                 className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
-                title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
+                title={theme === 'dark' ? t('nav.switchToLight') : t('nav.switchToDark')}
               >
                 {theme === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
               </button>

+ 55 - 90
frontend/src/components/NotificationProviderCard.tsx

@@ -6,6 +6,7 @@ import type { NotificationProvider, NotificationProviderUpdate } from '../api/cl
 import { Card, CardContent } from './Card';
 import { Button } from './Button';
 import { ConfirmModal } from './ConfirmModal';
+import { Toggle } from './Toggle';
 
 interface NotificationProviderCardProps {
   provider: NotificationProvider;
@@ -134,6 +135,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
             {provider.on_filament_low && (
               <span className="px-2 py-0.5 bg-amber-500/20 text-amber-400 text-xs rounded">Low Filament</span>
             )}
+            {provider.on_maintenance_due && (
+              <span className="px-2 py-0.5 bg-purple-500/20 text-purple-400 text-xs rounded">Maintenance</span>
+            )}
             {provider.quiet_hours_enabled && (
               <span className="px-2 py-0.5 bg-purple-500/20 text-purple-400 text-xs rounded flex items-center gap-1">
                 <Moon className="w-3 h-3" />
@@ -204,15 +208,10 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
                   <p className="text-sm text-white">Enabled</p>
                   <p className="text-xs text-bambu-gray">Send notifications from this provider</p>
                 </div>
-                <label className="relative inline-flex items-center cursor-pointer">
-                  <input
-                    type="checkbox"
-                    checked={provider.enabled}
-                    onChange={(e) => updateMutation.mutate({ enabled: e.target.checked })}
-                    className="sr-only peer"
-                  />
-                  <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
-                </label>
+                <Toggle
+                  checked={provider.enabled}
+                  onChange={(checked) => updateMutation.mutate({ enabled: checked })}
+                />
               </div>
 
               {/* Print Lifecycle Events */}
@@ -221,54 +220,34 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
 
                 <div className="flex items-center justify-between">
                   <p className="text-sm text-white">Print Started</p>
-                  <label className="relative inline-flex items-center cursor-pointer">
-                    <input
-                      type="checkbox"
-                      checked={provider.on_print_start}
-                      onChange={(e) => updateMutation.mutate({ on_print_start: e.target.checked })}
-                      className="sr-only peer"
-                    />
-                    <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
-                  </label>
+                  <Toggle
+                    checked={provider.on_print_start}
+                    onChange={(checked) => updateMutation.mutate({ on_print_start: checked })}
+                  />
                 </div>
 
                 <div className="flex items-center justify-between">
                   <p className="text-sm text-white">Print Completed</p>
-                  <label className="relative inline-flex items-center cursor-pointer">
-                    <input
-                      type="checkbox"
-                      checked={provider.on_print_complete}
-                      onChange={(e) => updateMutation.mutate({ on_print_complete: e.target.checked })}
-                      className="sr-only peer"
-                    />
-                    <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
-                  </label>
+                  <Toggle
+                    checked={provider.on_print_complete}
+                    onChange={(checked) => updateMutation.mutate({ on_print_complete: checked })}
+                  />
                 </div>
 
                 <div className="flex items-center justify-between">
                   <p className="text-sm text-white">Print Failed</p>
-                  <label className="relative inline-flex items-center cursor-pointer">
-                    <input
-                      type="checkbox"
-                      checked={provider.on_print_failed}
-                      onChange={(e) => updateMutation.mutate({ on_print_failed: e.target.checked })}
-                      className="sr-only peer"
-                    />
-                    <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
-                  </label>
+                  <Toggle
+                    checked={provider.on_print_failed}
+                    onChange={(checked) => updateMutation.mutate({ on_print_failed: checked })}
+                  />
                 </div>
 
                 <div className="flex items-center justify-between">
                   <p className="text-sm text-white">Print Stopped</p>
-                  <label className="relative inline-flex items-center cursor-pointer">
-                    <input
-                      type="checkbox"
-                      checked={provider.on_print_stopped}
-                      onChange={(e) => updateMutation.mutate({ on_print_stopped: e.target.checked })}
-                      className="sr-only peer"
-                    />
-                    <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
-                  </label>
+                  <Toggle
+                    checked={provider.on_print_stopped}
+                    onChange={(checked) => updateMutation.mutate({ on_print_stopped: checked })}
+                  />
                 </div>
 
                 <div className="flex items-center justify-between">
@@ -276,15 +255,10 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
                     <p className="text-sm text-white">Progress Milestones</p>
                     <p className="text-xs text-bambu-gray">Notify at 25%, 50%, 75%</p>
                   </div>
-                  <label className="relative inline-flex items-center cursor-pointer">
-                    <input
-                      type="checkbox"
-                      checked={provider.on_print_progress}
-                      onChange={(e) => updateMutation.mutate({ on_print_progress: e.target.checked })}
-                      className="sr-only peer"
-                    />
-                    <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
-                  </label>
+                  <Toggle
+                    checked={provider.on_print_progress}
+                    onChange={(checked) => updateMutation.mutate({ on_print_progress: checked })}
+                  />
                 </div>
               </div>
 
@@ -294,41 +268,37 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
 
                 <div className="flex items-center justify-between">
                   <p className="text-sm text-white">Printer Offline</p>
-                  <label className="relative inline-flex items-center cursor-pointer">
-                    <input
-                      type="checkbox"
-                      checked={provider.on_printer_offline}
-                      onChange={(e) => updateMutation.mutate({ on_printer_offline: e.target.checked })}
-                      className="sr-only peer"
-                    />
-                    <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
-                  </label>
+                  <Toggle
+                    checked={provider.on_printer_offline}
+                    onChange={(checked) => updateMutation.mutate({ on_printer_offline: checked })}
+                  />
                 </div>
 
                 <div className="flex items-center justify-between">
                   <p className="text-sm text-white">Printer Error</p>
-                  <label className="relative inline-flex items-center cursor-pointer">
-                    <input
-                      type="checkbox"
-                      checked={provider.on_printer_error}
-                      onChange={(e) => updateMutation.mutate({ on_printer_error: e.target.checked })}
-                      className="sr-only peer"
-                    />
-                    <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
-                  </label>
+                  <Toggle
+                    checked={provider.on_printer_error}
+                    onChange={(checked) => updateMutation.mutate({ on_printer_error: checked })}
+                  />
                 </div>
 
                 <div className="flex items-center justify-between">
                   <p className="text-sm text-white">Low Filament</p>
-                  <label className="relative inline-flex items-center cursor-pointer">
-                    <input
-                      type="checkbox"
-                      checked={provider.on_filament_low}
-                      onChange={(e) => updateMutation.mutate({ on_filament_low: e.target.checked })}
-                      className="sr-only peer"
-                    />
-                    <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
-                  </label>
+                  <Toggle
+                    checked={provider.on_filament_low}
+                    onChange={(checked) => updateMutation.mutate({ on_filament_low: checked })}
+                  />
+                </div>
+
+                <div className="flex items-center justify-between">
+                  <div>
+                    <p className="text-sm text-white">Maintenance Due</p>
+                    <p className="text-xs text-bambu-gray">Notify when maintenance is needed</p>
+                  </div>
+                  <Toggle
+                    checked={provider.on_maintenance_due ?? false}
+                    onChange={(checked) => updateMutation.mutate({ on_maintenance_due: checked })}
+                  />
                 </div>
               </div>
 
@@ -339,15 +309,10 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
                     <Moon className="w-4 h-4 text-purple-400" />
                     <p className="text-sm text-white">Quiet Hours</p>
                   </div>
-                  <label className="relative inline-flex items-center cursor-pointer">
-                    <input
-                      type="checkbox"
-                      checked={provider.quiet_hours_enabled}
-                      onChange={(e) => updateMutation.mutate({ quiet_hours_enabled: e.target.checked })}
-                      className="sr-only peer"
-                    />
-                    <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
-                  </label>
+                  <Toggle
+                    checked={provider.quiet_hours_enabled}
+                    onChange={(checked) => updateMutation.mutate({ quiet_hours_enabled: checked })}
+                  />
                 </div>
 
                 {provider.quiet_hours_enabled && (

+ 378 - 0
frontend/src/components/SpoolmanSettings.tsx

@@ -0,0 +1,378 @@
+import { useState, useEffect } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { Loader2, Check, X, RefreshCw, Link2, Link2Off, Database, ChevronDown } from 'lucide-react';
+import { api } from '../api/client';
+import type { SpoolmanSyncResult, Printer } from '../api/client';
+import { Card, CardContent, CardHeader } from './Card';
+import { Button } from './Button';
+
+interface SpoolmanSettingsData {
+  spoolman_enabled: string;
+  spoolman_url: string;
+  spoolman_sync_mode: string;
+}
+
+async function getSpoolmanSettings(): Promise<SpoolmanSettingsData> {
+  const response = await fetch('/api/v1/settings/spoolman');
+  if (!response.ok) {
+    throw new Error('Failed to load Spoolman settings');
+  }
+  return response.json();
+}
+
+async function updateSpoolmanSettings(data: Partial<SpoolmanSettingsData>): Promise<SpoolmanSettingsData> {
+  const response = await fetch('/api/v1/settings/spoolman', {
+    method: 'PUT',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(data),
+  });
+  if (!response.ok) {
+    throw new Error('Failed to save Spoolman settings');
+  }
+  return response.json();
+}
+
+export function SpoolmanSettings() {
+  const queryClient = useQueryClient();
+  const [localEnabled, setLocalEnabled] = useState(false);
+  const [localUrl, setLocalUrl] = useState('');
+  const [localSyncMode, setLocalSyncMode] = useState('auto');
+  const [hasChanges, setHasChanges] = useState(false);
+  const [showSaved, setShowSaved] = useState(false);
+  const [selectedPrinterId, setSelectedPrinterId] = useState<number | 'all'>('all');
+
+  // Fetch Spoolman settings
+  const { data: settings, isLoading: settingsLoading } = useQuery({
+    queryKey: ['spoolman-settings'],
+    queryFn: getSpoolmanSettings,
+  });
+
+  // Fetch Spoolman status
+  const { data: status, isLoading: statusLoading, refetch: refetchStatus } = useQuery({
+    queryKey: ['spoolman-status'],
+    queryFn: api.getSpoolmanStatus,
+    refetchInterval: 30000, // Refresh every 30 seconds
+  });
+
+  // Fetch printers for the dropdown
+  const { data: printers } = useQuery({
+    queryKey: ['printers'],
+    queryFn: api.getPrinters,
+  });
+
+  // Initialize local state from settings
+  useEffect(() => {
+    if (settings) {
+      setLocalEnabled(settings.spoolman_enabled === 'true');
+      setLocalUrl(settings.spoolman_url || '');
+      setLocalSyncMode(settings.spoolman_sync_mode || 'auto');
+    }
+  }, [settings]);
+
+  // Track changes
+  useEffect(() => {
+    if (settings) {
+      const changed =
+        (settings.spoolman_enabled === 'true') !== localEnabled ||
+        (settings.spoolman_url || '') !== localUrl ||
+        (settings.spoolman_sync_mode || 'auto') !== localSyncMode;
+      setHasChanges(changed);
+    }
+  }, [settings, localEnabled, localUrl, localSyncMode]);
+
+  // Save mutation
+  const saveMutation = useMutation({
+    mutationFn: () =>
+      updateSpoolmanSettings({
+        spoolman_enabled: localEnabled ? 'true' : 'false',
+        spoolman_url: localUrl,
+        spoolman_sync_mode: localSyncMode,
+      }),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['spoolman-settings'] });
+      queryClient.invalidateQueries({ queryKey: ['spoolman-status'] });
+      setHasChanges(false);
+      setShowSaved(true);
+      setTimeout(() => setShowSaved(false), 2000);
+    },
+  });
+
+  // Connect mutation
+  const connectMutation = useMutation({
+    mutationFn: api.connectSpoolman,
+    onSuccess: () => {
+      refetchStatus();
+    },
+  });
+
+  // Disconnect mutation
+  const disconnectMutation = useMutation({
+    mutationFn: api.disconnectSpoolman,
+    onSuccess: () => {
+      refetchStatus();
+    },
+  });
+
+  // Sync all mutation
+  const syncAllMutation = useMutation({
+    mutationFn: api.syncAllPrintersAms,
+    onSuccess: (data: SpoolmanSyncResult) => {
+      if (data.success) {
+        // Show success message
+      }
+    },
+  });
+
+  // Sync single printer mutation
+  const syncPrinterMutation = useMutation({
+    mutationFn: (printerId: number) => api.syncPrinterAms(printerId),
+    onSuccess: (data: SpoolmanSyncResult) => {
+      if (data.success) {
+        // Show success message
+      }
+    },
+  });
+
+  // Helper to handle sync based on selection
+  const handleSync = () => {
+    if (selectedPrinterId === 'all') {
+      syncAllMutation.mutate();
+    } else {
+      syncPrinterMutation.mutate(selectedPrinterId);
+    }
+  };
+
+  // Combine mutation states
+  const isSyncing = syncAllMutation.isPending || syncPrinterMutation.isPending;
+  const syncResult = selectedPrinterId === 'all' ? syncAllMutation.data : syncPrinterMutation.data;
+  const syncSuccess = selectedPrinterId === 'all' ? syncAllMutation.isSuccess : syncPrinterMutation.isSuccess;
+
+  if (settingsLoading) {
+    return (
+      <Card>
+        <CardHeader>
+          <div className="flex items-center gap-2">
+            <Database className="w-5 h-5 text-bambu-green" />
+            <h2 className="text-lg font-semibold text-white">Spoolman Integration</h2>
+          </div>
+        </CardHeader>
+        <CardContent>
+          <div className="flex justify-center py-8">
+            <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
+          </div>
+        </CardContent>
+      </Card>
+    );
+  }
+
+  return (
+    <Card>
+      <CardHeader>
+        <div className="flex items-center justify-between">
+          <div className="flex items-center gap-2">
+            <Database className="w-5 h-5 text-bambu-green" />
+            <h2 className="text-lg font-semibold text-white">Spoolman Integration</h2>
+          </div>
+          {hasChanges && (
+            <Button
+              size="sm"
+              onClick={() => saveMutation.mutate()}
+              disabled={saveMutation.isPending}
+            >
+              {saveMutation.isPending ? (
+                <Loader2 className="w-4 h-4 animate-spin" />
+              ) : showSaved ? (
+                <Check className="w-4 h-4" />
+              ) : (
+                'Save'
+              )}
+            </Button>
+          )}
+        </div>
+      </CardHeader>
+      <CardContent className="space-y-4">
+        <p className="text-sm text-bambu-gray">
+          Connect to Spoolman for filament inventory tracking. AMS data will sync automatically.
+        </p>
+
+        {/* Enable toggle */}
+        <div className="flex items-center justify-between">
+          <div>
+            <p className="text-white">Enable Spoolman</p>
+            <p className="text-sm text-bambu-gray">
+              Sync filament data with Spoolman server
+            </p>
+          </div>
+          <label className="relative inline-flex items-center cursor-pointer">
+            <input
+              type="checkbox"
+              checked={localEnabled}
+              onChange={(e) => setLocalEnabled(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>
+
+        {/* URL input */}
+        <div>
+          <label className="block text-sm text-bambu-gray mb-1">
+            Spoolman URL
+          </label>
+          <input
+            type="text"
+            placeholder="http://192.168.1.100:7912"
+            value={localUrl}
+            onChange={(e) => setLocalUrl(e.target.value)}
+            disabled={!localEnabled}
+            className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray/50 focus:border-bambu-green focus:outline-none disabled:opacity-50"
+          />
+          <p className="text-xs text-bambu-gray mt-1">
+            URL of your Spoolman server (e.g., http://localhost:7912)
+          </p>
+        </div>
+
+        {/* Sync mode */}
+        <div>
+          <label className="block text-sm text-bambu-gray mb-1">
+            Sync Mode
+          </label>
+          <select
+            value={localSyncMode}
+            onChange={(e) => setLocalSyncMode(e.target.value)}
+            disabled={!localEnabled}
+            className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none disabled:opacity-50"
+          >
+            <option value="auto">Automatic</option>
+            <option value="manual">Manual Only</option>
+          </select>
+          <p className="text-xs text-bambu-gray mt-1">
+            {localSyncMode === 'auto'
+              ? 'AMS data syncs automatically when changes are detected'
+              : 'Only sync when manually triggered'}
+          </p>
+        </div>
+
+        {/* Connection status */}
+        {localEnabled && (
+          <div className="pt-2 border-t border-bambu-dark-tertiary">
+            <div className="flex items-center justify-between mb-3">
+              <div className="flex items-center gap-2">
+                <span className="text-sm text-bambu-gray">Status:</span>
+                {statusLoading ? (
+                  <Loader2 className="w-4 h-4 text-bambu-gray animate-spin" />
+                ) : status?.connected ? (
+                  <span className="flex items-center gap-1 text-sm text-green-500">
+                    <Check className="w-4 h-4" />
+                    Connected
+                  </span>
+                ) : (
+                  <span className="flex items-center gap-1 text-sm text-red-500">
+                    <X className="w-4 h-4" />
+                    Disconnected
+                  </span>
+                )}
+              </div>
+              <div className="flex gap-2">
+                {status?.connected ? (
+                  <Button
+                    variant="secondary"
+                    size="sm"
+                    onClick={() => disconnectMutation.mutate()}
+                    disabled={disconnectMutation.isPending}
+                  >
+                    {disconnectMutation.isPending ? (
+                      <Loader2 className="w-4 h-4 animate-spin" />
+                    ) : (
+                      <Link2Off className="w-4 h-4" />
+                    )}
+                    Disconnect
+                  </Button>
+                ) : (
+                  <Button
+                    size="sm"
+                    onClick={() => connectMutation.mutate()}
+                    disabled={connectMutation.isPending || !localUrl}
+                  >
+                    {connectMutation.isPending ? (
+                      <Loader2 className="w-4 h-4 animate-spin" />
+                    ) : (
+                      <Link2 className="w-4 h-4" />
+                    )}
+                    Connect
+                  </Button>
+                )}
+              </div>
+            </div>
+
+            {/* Error display */}
+            {connectMutation.isError && (
+              <div className="mb-3 p-2 bg-red-500/20 border border-red-500/50 rounded text-sm text-red-400">
+                {(connectMutation.error as Error).message}
+              </div>
+            )}
+
+            {/* Manual sync section */}
+            {status?.connected && (
+              <div className="space-y-3">
+                <div>
+                  <p className="text-sm text-white">Sync AMS Data</p>
+                  <p className="text-xs text-bambu-gray">
+                    Manually sync printer AMS data to Spoolman
+                  </p>
+                </div>
+                <div className="flex items-center gap-2">
+                  {/* Printer selector */}
+                  <div className="relative flex-1">
+                    <select
+                      value={selectedPrinterId}
+                      onChange={(e) => setSelectedPrinterId(e.target.value === 'all' ? 'all' : Number(e.target.value))}
+                      className="w-full px-3 py-2 pr-8 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
+                    >
+                      <option value="all">All Printers</option>
+                      {printers?.map((printer: Printer) => (
+                        <option key={printer.id} value={printer.id}>
+                          {printer.name}
+                        </option>
+                      ))}
+                    </select>
+                    <ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
+                  </div>
+                  {/* Sync button */}
+                  <Button
+                    variant="secondary"
+                    size="sm"
+                    onClick={handleSync}
+                    disabled={isSyncing}
+                  >
+                    {isSyncing ? (
+                      <Loader2 className="w-4 h-4 animate-spin" />
+                    ) : (
+                      <RefreshCw className="w-4 h-4" />
+                    )}
+                    Sync
+                  </Button>
+                </div>
+              </div>
+            )}
+
+            {/* Sync result */}
+            {syncSuccess && syncResult && (
+              <div
+                className={`mt-2 p-2 rounded text-sm ${
+                  syncResult.success
+                    ? 'bg-green-500/20 border border-green-500/50 text-green-400'
+                    : 'bg-yellow-500/20 border border-yellow-500/50 text-yellow-400'
+                }`}
+              >
+                {syncResult.success
+                  ? `Synced ${syncResult.synced_count} trays successfully`
+                  : `Synced ${syncResult.synced_count} trays with ${syncResult.errors.length} errors`}
+              </div>
+            )}
+          </div>
+        )}
+      </CardContent>
+    </Card>
+  );
+}

+ 38 - 0
frontend/src/components/Toggle.tsx

@@ -0,0 +1,38 @@
+interface ToggleProps {
+  checked: boolean;
+  onChange: (checked: boolean) => void;
+  disabled?: boolean;
+}
+
+export function Toggle({ checked, onChange, disabled }: ToggleProps) {
+  const handleClick = (e: React.MouseEvent) => {
+    e.preventDefault();
+    e.stopPropagation();
+    if (!disabled) {
+      onChange(!checked);
+    }
+  };
+
+  return (
+    <button
+      type="button"
+      role="switch"
+      aria-checked={checked}
+      disabled={disabled}
+      onClick={handleClick}
+      className={`relative inline-flex w-9 h-5 rounded-full transition-colors flex-shrink-0 focus:outline-none focus:ring-2 focus:ring-bambu-green focus:ring-offset-2 focus:ring-offset-bambu-dark ${
+        disabled
+          ? 'bg-bambu-dark-tertiary/50 cursor-not-allowed opacity-50'
+          : checked
+          ? 'bg-bambu-green cursor-pointer'
+          : 'bg-bambu-dark-tertiary cursor-pointer hover:bg-bambu-dark-tertiary/80'
+      }`}
+    >
+      <span
+        className={`pointer-events-none absolute top-[2px] left-[2px] w-4 h-4 bg-white rounded-full shadow transition-transform duration-200 ease-in-out ${
+          checked ? 'translate-x-4' : 'translate-x-0'
+        }`}
+      />
+    </button>
+  );
+}

+ 46 - 0
frontend/src/i18n/index.ts

@@ -0,0 +1,46 @@
+import i18n from 'i18next';
+import { initReactI18next } from 'react-i18next';
+import LanguageDetector from 'i18next-browser-languagedetector';
+
+// Import translations directly for bundling
+import en from './locales/en';
+import de from './locales/de';
+
+const resources = {
+  en: { translation: en },
+  de: { translation: de },
+};
+
+i18n
+  .use(LanguageDetector)
+  .use(initReactI18next)
+  .init({
+    resources,
+    fallbackLng: 'en',
+    supportedLngs: ['en', 'de'],
+
+    detection: {
+      // Order of detection methods
+      order: ['localStorage', 'navigator', 'htmlTag'],
+      // Key to use in localStorage
+      lookupLocalStorage: 'bambutrack_language',
+      // Cache user language
+      caches: ['localStorage'],
+    },
+
+    interpolation: {
+      escapeValue: false, // React already escapes
+    },
+
+    react: {
+      useSuspense: false,
+    },
+  });
+
+export default i18n;
+
+// Helper to get available languages
+export const availableLanguages = [
+  { code: 'en', name: 'English', nativeName: 'English' },
+  { code: 'de', name: 'German', nativeName: 'Deutsch' },
+];

+ 358 - 0
frontend/src/i18n/locales/de.ts

@@ -0,0 +1,358 @@
+export default {
+  // Navigation
+  nav: {
+    printers: 'Drucker',
+    archives: 'Archiv',
+    queue: 'Warteschlange',
+    stats: 'Statistiken',
+    profiles: 'Profile',
+    maintenance: 'Wartung',
+    settings: 'Einstellungen',
+    collapseSidebar: 'Seitenleiste einklappen',
+    expandSidebar: 'Seitenleiste ausklappen',
+    update: 'Update',
+    updateAvailable: 'Update verfügbar: v{{version}}',
+    viewOnGithub: 'Auf GitHub ansehen',
+    keyboardShortcuts: 'Tastaturkürzel (?)',
+    switchToLight: 'Zum hellen Modus wechseln',
+    switchToDark: 'Zum dunklen Modus wechseln',
+  },
+
+  // Common
+  common: {
+    save: 'Speichern',
+    cancel: 'Abbrechen',
+    delete: 'Löschen',
+    edit: 'Bearbeiten',
+    add: 'Hinzufügen',
+    close: 'Schließen',
+    confirm: 'Bestätigen',
+    loading: 'Lädt...',
+    error: 'Fehler',
+    success: 'Erfolg',
+    warning: 'Warnung',
+    enabled: 'Aktiviert',
+    disabled: 'Deaktiviert',
+    yes: 'Ja',
+    no: 'Nein',
+    on: 'An',
+    off: 'Aus',
+    all: 'Alle',
+    none: 'Keine',
+    search: 'Suchen',
+    filter: 'Filtern',
+    sort: 'Sortieren',
+    refresh: 'Aktualisieren',
+    download: 'Herunterladen',
+    upload: 'Hochladen',
+    actions: 'Aktionen',
+    status: 'Status',
+    name: 'Name',
+    description: 'Beschreibung',
+    date: 'Datum',
+    time: 'Zeit',
+    hours: 'Stunden',
+    minutes: 'Minuten',
+    seconds: 'Sekunden',
+    noPrinters: 'Keine Drucker konfiguriert',
+    noData: 'Keine Daten verfügbar',
+    required: 'Erforderlich',
+    optional: 'Optional',
+  },
+
+  // Printers page
+  printers: {
+    title: 'Drucker',
+    addPrinter: 'Drucker hinzufügen',
+    editPrinter: 'Drucker bearbeiten',
+    deletePrinter: 'Drucker löschen',
+    printerName: 'Druckername',
+    serialNumber: 'Seriennummer',
+    ipAddress: 'IP-Adresse',
+    accessCode: 'Zugangscode',
+    model: 'Modell',
+    nozzleCount: 'Düsenanzahl',
+    autoArchive: 'Automatische Archivierung',
+    status: {
+      idle: 'Bereit',
+      printing: 'Druckt',
+      paused: 'Pausiert',
+      offline: 'Offline',
+      error: 'Fehler',
+      finished: 'Fertig',
+      unknown: 'Unbekannt',
+    },
+    temperatures: {
+      nozzle: 'Düse',
+      bed: 'Druckbett',
+      chamber: 'Kammer',
+    },
+    progress: '{{percent}}% abgeschlossen',
+    timeRemaining: 'Noch {{time}}',
+    deleteConfirm: 'Möchten Sie "{{name}}" wirklich löschen?',
+    maintenanceOk: 'Wartung OK',
+    maintenanceWarning: '{{count}} Warnung',
+    maintenanceWarning_plural: '{{count}} Warnungen',
+    maintenanceDue: '{{count}} fällig',
+    maintenanceDue_plural: '{{count}} fällig',
+  },
+
+  // Archives page
+  archives: {
+    title: 'Druckarchiv',
+    searchPlaceholder: 'Archiv durchsuchen...',
+    filterByPrinter: 'Nach Drucker filtern',
+    filterByStatus: 'Nach Status filtern',
+    sortBy: 'Sortieren nach',
+    sortNewest: 'Neueste zuerst',
+    sortOldest: 'Älteste zuerst',
+    sortName: 'Name',
+    sortDuration: 'Dauer',
+    noArchives: 'Keine Archive gefunden',
+    printTime: 'Druckzeit',
+    filamentUsed: 'Verbrauchtes Filament',
+    cost: 'Kosten',
+    reprint: 'Erneut drucken',
+    preview: 'Vorschau',
+    deleteArchive: 'Archiv löschen',
+    deleteConfirm: 'Möchten Sie dieses Archiv wirklich löschen?',
+    favorite: 'Favorit',
+    unfavorite: 'Aus Favoriten entfernen',
+    viewDetails: 'Details anzeigen',
+    status: {
+      completed: 'Abgeschlossen',
+      failed: 'Fehlgeschlagen',
+      stopped: 'Gestoppt',
+    },
+  },
+
+  // Queue page
+  queue: {
+    title: 'Druckwarteschlange',
+    addToQueue: 'Zur Warteschlange hinzufügen',
+    clearQueue: 'Warteschlange leeren',
+    emptyQueue: 'Warteschlange ist leer',
+    position: 'Position',
+    scheduledTime: 'Geplante Zeit',
+    moveUp: 'Nach oben',
+    moveDown: 'Nach unten',
+    remove: 'Entfernen',
+    startNow: 'Jetzt starten',
+    status: {
+      pending: 'Ausstehend',
+      printing: 'Druckt',
+      completed: 'Abgeschlossen',
+      failed: 'Fehlgeschlagen',
+      cancelled: 'Abgebrochen',
+    },
+  },
+
+  // Statistics page
+  stats: {
+    title: 'Statistiken',
+    overview: 'Übersicht',
+    totalPrints: 'Gesamtdrucke',
+    successRate: 'Erfolgsrate',
+    totalPrintTime: 'Gesamtdruckzeit',
+    totalFilament: 'Gesamtverbrauch Filament',
+    totalCost: 'Gesamtkosten',
+    averagePrintTime: 'Durchschnittliche Druckzeit',
+    printsPerDay: 'Drucke pro Tag',
+    byPrinter: 'Nach Drucker',
+    byMaterial: 'Nach Material',
+    byMonth: 'Nach Monat',
+    last7Days: 'Letzte 7 Tage',
+    last30Days: 'Letzte 30 Tage',
+    last90Days: 'Letzte 90 Tage',
+    allTime: 'Gesamt',
+  },
+
+  // Profiles page
+  profiles: {
+    title: 'Filament-Profile',
+    addProfile: 'Profil hinzufügen',
+    editProfile: 'Profil bearbeiten',
+    deleteProfile: 'Profil löschen',
+    material: 'Material',
+    brand: 'Marke',
+    color: 'Farbe',
+    diameter: 'Durchmesser',
+    density: 'Dichte',
+    costPerKg: 'Kosten pro kg',
+    spoolWeight: 'Spulengewicht',
+    noProfiles: 'Keine Profile konfiguriert',
+    deleteConfirm: 'Möchten Sie dieses Profil wirklich löschen?',
+  },
+
+  // Maintenance page
+  maintenance: {
+    title: 'Wartung',
+    overview: 'Übersicht',
+    allOk: 'Alle Wartungen aktuell',
+    dueCount: '{{count}} Aufgabe fällig',
+    dueCount_plural: '{{count}} Aufgaben fällig',
+    warningCount: '{{count}} Warnung',
+    warningCount_plural: '{{count}} Warnungen',
+    totalPrintTime: 'Gesamtdruckzeit',
+    nextMaintenance: 'Nächste Wartung',
+    nothingDue: 'Nichts fällig',
+    tasks: 'Aufgaben',
+    lastPerformed: 'Zuletzt durchgeführt',
+    interval: 'Intervall',
+    hoursRemaining: '{{hours}}h verbleibend',
+    hoursOverdue: '{{hours}}h überfällig',
+    markDone: 'Als erledigt markieren',
+    performMaintenance: 'Wartung durchführen',
+    history: 'Verlauf',
+    noHistory: 'Kein Wartungsverlauf',
+    editPrintHours: 'Druckstunden bearbeiten',
+    currentHours: 'Aktuelle Stunden',
+    types: {
+      lubricateRails: 'Linearschienen schmieren',
+      cleanNozzle: 'Düse/Hotend reinigen',
+      checkBelts: 'Riemenspannung prüfen',
+      cleanBuildPlate: 'Druckbett reinigen',
+      checkExtruder: 'Extruderzahnräder prüfen',
+      checkCooling: 'Kühlungslüfter prüfen',
+      generalInspection: 'Allgemeine Inspektion',
+    },
+  },
+
+  // Settings page
+  settings: {
+    title: 'Einstellungen',
+    general: 'Allgemein',
+    appearance: 'Erscheinungsbild',
+    notifications: 'Benachrichtigungen',
+    smartPlugs: 'Smart Plugs',
+    spoolman: 'Spoolman',
+    updates: 'Updates',
+    language: 'Sprache',
+    languageDescription: 'Wählen Sie Ihre bevorzugte Sprache',
+    theme: 'Design',
+    themeLight: 'Hell',
+    themeDark: 'Dunkel',
+    themeSystem: 'System',
+    defaultView: 'Standardansicht',
+    defaultViewDescription: 'Seite, die beim Öffnen der App angezeigt wird',
+    checkForUpdates: 'Nach Updates suchen',
+    autoUpdate: 'Automatische Updates',
+    currentVersion: 'Aktuelle Version',
+    latestVersion: 'Neueste Version',
+    upToDate: 'Sie sind auf dem neuesten Stand',
+    updateAvailable: 'Update verfügbar',
+    // Notifications
+    notificationLanguage: 'Benachrichtigungssprache',
+    notificationLanguageDescription: 'Sprache für Push-Benachrichtigungen',
+    notificationProviders: 'Benachrichtigungsanbieter',
+    addProvider: 'Anbieter hinzufügen',
+    editProvider: 'Anbieter bearbeiten',
+    providerType: 'Anbietertyp',
+    testNotification: 'Testbenachrichtigung',
+    testSuccess: 'Testbenachrichtigung erfolgreich gesendet',
+    testFailed: 'Testbenachrichtigung konnte nicht gesendet werden',
+    quietHours: 'Ruhezeiten',
+    quietHoursDescription: 'Keine Störungen während dieser Zeiten',
+    quietHoursStart: 'Beginn',
+    quietHoursEnd: 'Ende',
+    events: {
+      title: 'Benachrichtigungsereignisse',
+      printStart: 'Druck gestartet',
+      printComplete: 'Druck abgeschlossen',
+      printFailed: 'Druck fehlgeschlagen',
+      printStopped: 'Druck gestoppt',
+      printProgress: 'Fortschrittsmeldungen',
+      printProgressDescription: 'Bei 25%, 50%, 75% benachrichtigen',
+      printerOffline: 'Drucker offline',
+      printerError: 'Druckerfehler',
+      filamentLow: 'Filament niedrig',
+      maintenanceDue: 'Wartung fällig',
+      maintenanceDueDescription: 'Benachrichtigen, wenn Wartung erforderlich',
+    },
+    // Smart Plugs
+    smartPlug: {
+      title: 'Smart Plugs',
+      add: 'Smart Plug hinzufügen',
+      edit: 'Smart Plug bearbeiten',
+      name: 'Name',
+      ipAddress: 'IP-Adresse',
+      linkedPrinter: 'Verknüpfter Drucker',
+      autoOn: 'Automatisch einschalten',
+      autoOnDescription: 'Einschalten beim Druckstart',
+      autoOff: 'Automatisch ausschalten',
+      autoOffDescription: 'Ausschalten nach Druckende',
+      offDelay: 'Ausschaltverzögerung',
+      offDelayMinutes: 'Minuten nach Druck',
+      offDelayTemp: 'Wenn Düse unter Temperatur',
+      currentState: 'Aktueller Status',
+      turnOn: 'Einschalten',
+      turnOff: 'Ausschalten',
+    },
+    // Spoolman
+    spoolmanEnabled: 'Spoolman-Integration aktivieren',
+    spoolmanUrl: 'Spoolman URL',
+    spoolmanConnected: 'Verbunden',
+    spoolmanDisconnected: 'Nicht verbunden',
+  },
+
+  // Notifications (for push notifications)
+  notification: {
+    printStarted: {
+      title: 'Druck gestartet',
+      body: '{{printer}}: {{filename}} wird gedruckt',
+    },
+    printCompleted: {
+      title: 'Druck abgeschlossen',
+      body: '{{printer}}: {{filename}} erfolgreich abgeschlossen',
+    },
+    printFailed: {
+      title: 'Druck fehlgeschlagen',
+      body: '{{printer}}: {{filename}} ist fehlgeschlagen',
+    },
+    printStopped: {
+      title: 'Druck gestoppt',
+      body: '{{printer}}: {{filename}} wurde gestoppt',
+    },
+    printProgress: {
+      title: 'Druckfortschritt',
+      body: '{{printer}}: {{filename}} ist zu {{percent}}% abgeschlossen',
+    },
+    printerOffline: {
+      title: 'Drucker offline',
+      body: '{{printer}} ist offline',
+    },
+    printerError: {
+      title: 'Druckerfehler',
+      body: '{{printer}}: {{error}}',
+    },
+    filamentLow: {
+      title: 'Filament niedrig',
+      body: '{{printer}}: Filament geht zur Neige',
+    },
+    maintenanceDue: {
+      title: 'Wartung fällig',
+      body: '{{printer}}: {{items}} benötigen Aufmerksamkeit',
+    },
+  },
+
+  // Errors
+  errors: {
+    generic: 'Etwas ist schiefgelaufen',
+    networkError: 'Netzwerkfehler. Bitte überprüfen Sie Ihre Verbindung.',
+    notFound: 'Nicht gefunden',
+    unauthorized: 'Nicht autorisiert',
+    serverError: 'Serverfehler',
+    validationError: 'Bitte überprüfen Sie Ihre Eingabe',
+    printerConnectionFailed: 'Verbindung zum Drucker fehlgeschlagen',
+    saveFailed: 'Speichern fehlgeschlagen',
+    deleteFailed: 'Löschen fehlgeschlagen',
+    loadFailed: 'Laden der Daten fehlgeschlagen',
+  },
+
+  // Confirmations
+  confirm: {
+    delete: 'Möchten Sie dies wirklich löschen?',
+    unsavedChanges: 'Sie haben ungespeicherte Änderungen. Möchten Sie wirklich verlassen?',
+    clearQueue: 'Möchten Sie die Warteschlange wirklich leeren?',
+  },
+};

+ 358 - 0
frontend/src/i18n/locales/en.ts

@@ -0,0 +1,358 @@
+export default {
+  // Navigation
+  nav: {
+    printers: 'Printers',
+    archives: 'Archives',
+    queue: 'Queue',
+    stats: 'Statistics',
+    profiles: 'Profiles',
+    maintenance: 'Maintenance',
+    settings: 'Settings',
+    collapseSidebar: 'Collapse sidebar',
+    expandSidebar: 'Expand sidebar',
+    update: 'Update',
+    updateAvailable: 'Update available: v{{version}}',
+    viewOnGithub: 'View on GitHub',
+    keyboardShortcuts: 'Keyboard shortcuts (?)',
+    switchToLight: 'Switch to light mode',
+    switchToDark: 'Switch to dark mode',
+  },
+
+  // Common
+  common: {
+    save: 'Save',
+    cancel: 'Cancel',
+    delete: 'Delete',
+    edit: 'Edit',
+    add: 'Add',
+    close: 'Close',
+    confirm: 'Confirm',
+    loading: 'Loading...',
+    error: 'Error',
+    success: 'Success',
+    warning: 'Warning',
+    enabled: 'Enabled',
+    disabled: 'Disabled',
+    yes: 'Yes',
+    no: 'No',
+    on: 'On',
+    off: 'Off',
+    all: 'All',
+    none: 'None',
+    search: 'Search',
+    filter: 'Filter',
+    sort: 'Sort',
+    refresh: 'Refresh',
+    download: 'Download',
+    upload: 'Upload',
+    actions: 'Actions',
+    status: 'Status',
+    name: 'Name',
+    description: 'Description',
+    date: 'Date',
+    time: 'Time',
+    hours: 'hours',
+    minutes: 'minutes',
+    seconds: 'seconds',
+    noPrinters: 'No printers configured',
+    noData: 'No data available',
+    required: 'Required',
+    optional: 'Optional',
+  },
+
+  // Printers page
+  printers: {
+    title: 'Printers',
+    addPrinter: 'Add Printer',
+    editPrinter: 'Edit Printer',
+    deletePrinter: 'Delete Printer',
+    printerName: 'Printer Name',
+    serialNumber: 'Serial Number',
+    ipAddress: 'IP Address',
+    accessCode: 'Access Code',
+    model: 'Model',
+    nozzleCount: 'Nozzle Count',
+    autoArchive: 'Auto Archive',
+    status: {
+      idle: 'Idle',
+      printing: 'Printing',
+      paused: 'Paused',
+      offline: 'Offline',
+      error: 'Error',
+      finished: 'Finished',
+      unknown: 'Unknown',
+    },
+    temperatures: {
+      nozzle: 'Nozzle',
+      bed: 'Bed',
+      chamber: 'Chamber',
+    },
+    progress: '{{percent}}% complete',
+    timeRemaining: '{{time}} remaining',
+    deleteConfirm: 'Are you sure you want to delete "{{name}}"?',
+    maintenanceOk: 'Maintenance OK',
+    maintenanceWarning: '{{count}} warning',
+    maintenanceWarning_plural: '{{count}} warnings',
+    maintenanceDue: '{{count}} due',
+    maintenanceDue_plural: '{{count}} due',
+  },
+
+  // Archives page
+  archives: {
+    title: 'Print Archives',
+    searchPlaceholder: 'Search archives...',
+    filterByPrinter: 'Filter by printer',
+    filterByStatus: 'Filter by status',
+    sortBy: 'Sort by',
+    sortNewest: 'Newest first',
+    sortOldest: 'Oldest first',
+    sortName: 'Name',
+    sortDuration: 'Duration',
+    noArchives: 'No archives found',
+    printTime: 'Print Time',
+    filamentUsed: 'Filament Used',
+    cost: 'Cost',
+    reprint: 'Reprint',
+    preview: 'Preview',
+    deleteArchive: 'Delete Archive',
+    deleteConfirm: 'Are you sure you want to delete this archive?',
+    favorite: 'Favorite',
+    unfavorite: 'Remove from favorites',
+    viewDetails: 'View Details',
+    status: {
+      completed: 'Completed',
+      failed: 'Failed',
+      stopped: 'Stopped',
+    },
+  },
+
+  // Queue page
+  queue: {
+    title: 'Print Queue',
+    addToQueue: 'Add to Queue',
+    clearQueue: 'Clear Queue',
+    emptyQueue: 'Queue is empty',
+    position: 'Position',
+    scheduledTime: 'Scheduled Time',
+    moveUp: 'Move Up',
+    moveDown: 'Move Down',
+    remove: 'Remove',
+    startNow: 'Start Now',
+    status: {
+      pending: 'Pending',
+      printing: 'Printing',
+      completed: 'Completed',
+      failed: 'Failed',
+      cancelled: 'Cancelled',
+    },
+  },
+
+  // Statistics page
+  stats: {
+    title: 'Statistics',
+    overview: 'Overview',
+    totalPrints: 'Total Prints',
+    successRate: 'Success Rate',
+    totalPrintTime: 'Total Print Time',
+    totalFilament: 'Total Filament Used',
+    totalCost: 'Total Cost',
+    averagePrintTime: 'Average Print Time',
+    printsPerDay: 'Prints per Day',
+    byPrinter: 'By Printer',
+    byMaterial: 'By Material',
+    byMonth: 'By Month',
+    last7Days: 'Last 7 Days',
+    last30Days: 'Last 30 Days',
+    last90Days: 'Last 90 Days',
+    allTime: 'All Time',
+  },
+
+  // Profiles page
+  profiles: {
+    title: 'Filament Profiles',
+    addProfile: 'Add Profile',
+    editProfile: 'Edit Profile',
+    deleteProfile: 'Delete Profile',
+    material: 'Material',
+    brand: 'Brand',
+    color: 'Color',
+    diameter: 'Diameter',
+    density: 'Density',
+    costPerKg: 'Cost per kg',
+    spoolWeight: 'Spool Weight',
+    noProfiles: 'No profiles configured',
+    deleteConfirm: 'Are you sure you want to delete this profile?',
+  },
+
+  // Maintenance page
+  maintenance: {
+    title: 'Maintenance',
+    overview: 'Overview',
+    allOk: 'All maintenance up to date',
+    dueCount: '{{count}} item due',
+    dueCount_plural: '{{count}} items due',
+    warningCount: '{{count}} warning',
+    warningCount_plural: '{{count}} warnings',
+    totalPrintTime: 'Total Print Time',
+    nextMaintenance: 'Next Maintenance',
+    nothingDue: 'Nothing due',
+    tasks: 'Tasks',
+    lastPerformed: 'Last performed',
+    interval: 'Interval',
+    hoursRemaining: '{{hours}}h remaining',
+    hoursOverdue: '{{hours}}h overdue',
+    markDone: 'Mark as Done',
+    performMaintenance: 'Perform Maintenance',
+    history: 'History',
+    noHistory: 'No maintenance history',
+    editPrintHours: 'Edit Print Hours',
+    currentHours: 'Current Hours',
+    types: {
+      lubricateRails: 'Lubricate Linear Rails',
+      cleanNozzle: 'Clean Nozzle/Hotend',
+      checkBelts: 'Check Belt Tension',
+      cleanBuildPlate: 'Clean Build Plate',
+      checkExtruder: 'Check Extruder Gears',
+      checkCooling: 'Check Cooling Fans',
+      generalInspection: 'General Inspection',
+    },
+  },
+
+  // Settings page
+  settings: {
+    title: 'Settings',
+    general: 'General',
+    appearance: 'Appearance',
+    notifications: 'Notifications',
+    smartPlugs: 'Smart Plugs',
+    spoolman: 'Spoolman',
+    updates: 'Updates',
+    language: 'Language',
+    languageDescription: 'Select your preferred language',
+    theme: 'Theme',
+    themeLight: 'Light',
+    themeDark: 'Dark',
+    themeSystem: 'System',
+    defaultView: 'Default View',
+    defaultViewDescription: 'Page to show when opening the app',
+    checkForUpdates: 'Check for Updates',
+    autoUpdate: 'Auto Update',
+    currentVersion: 'Current Version',
+    latestVersion: 'Latest Version',
+    upToDate: 'You are up to date',
+    updateAvailable: 'Update available',
+    // Notifications
+    notificationLanguage: 'Notification Language',
+    notificationLanguageDescription: 'Language for push notifications',
+    notificationProviders: 'Notification Providers',
+    addProvider: 'Add Provider',
+    editProvider: 'Edit Provider',
+    providerType: 'Provider Type',
+    testNotification: 'Test Notification',
+    testSuccess: 'Test notification sent successfully',
+    testFailed: 'Failed to send test notification',
+    quietHours: 'Quiet Hours',
+    quietHoursDescription: 'Do not disturb during these hours',
+    quietHoursStart: 'Start',
+    quietHoursEnd: 'End',
+    events: {
+      title: 'Notification Events',
+      printStart: 'Print Started',
+      printComplete: 'Print Completed',
+      printFailed: 'Print Failed',
+      printStopped: 'Print Stopped',
+      printProgress: 'Progress Milestones',
+      printProgressDescription: 'Notify at 25%, 50%, 75%',
+      printerOffline: 'Printer Offline',
+      printerError: 'Printer Error',
+      filamentLow: 'Low Filament',
+      maintenanceDue: 'Maintenance Due',
+      maintenanceDueDescription: 'Notify when maintenance is needed',
+    },
+    // Smart Plugs
+    smartPlug: {
+      title: 'Smart Plugs',
+      add: 'Add Smart Plug',
+      edit: 'Edit Smart Plug',
+      name: 'Name',
+      ipAddress: 'IP Address',
+      linkedPrinter: 'Linked Printer',
+      autoOn: 'Auto Power On',
+      autoOnDescription: 'Turn on when print starts',
+      autoOff: 'Auto Power Off',
+      autoOffDescription: 'Turn off after print completes',
+      offDelay: 'Off Delay',
+      offDelayMinutes: 'Minutes after print',
+      offDelayTemp: 'When nozzle below temperature',
+      currentState: 'Current State',
+      turnOn: 'Turn On',
+      turnOff: 'Turn Off',
+    },
+    // Spoolman
+    spoolmanEnabled: 'Enable Spoolman Integration',
+    spoolmanUrl: 'Spoolman URL',
+    spoolmanConnected: 'Connected',
+    spoolmanDisconnected: 'Disconnected',
+  },
+
+  // Notifications (for push notifications)
+  notification: {
+    printStarted: {
+      title: 'Print Started',
+      body: '{{printer}}: {{filename}} has started printing',
+    },
+    printCompleted: {
+      title: 'Print Completed',
+      body: '{{printer}}: {{filename}} completed successfully',
+    },
+    printFailed: {
+      title: 'Print Failed',
+      body: '{{printer}}: {{filename}} has failed',
+    },
+    printStopped: {
+      title: 'Print Stopped',
+      body: '{{printer}}: {{filename}} was stopped',
+    },
+    printProgress: {
+      title: 'Print Progress',
+      body: '{{printer}}: {{filename}} is {{percent}}% complete',
+    },
+    printerOffline: {
+      title: 'Printer Offline',
+      body: '{{printer}} is offline',
+    },
+    printerError: {
+      title: 'Printer Error',
+      body: '{{printer}}: {{error}}',
+    },
+    filamentLow: {
+      title: 'Low Filament',
+      body: '{{printer}}: Filament is running low',
+    },
+    maintenanceDue: {
+      title: 'Maintenance Due',
+      body: '{{printer}}: {{items}} need attention',
+    },
+  },
+
+  // Errors
+  errors: {
+    generic: 'Something went wrong',
+    networkError: 'Network error. Please check your connection.',
+    notFound: 'Not found',
+    unauthorized: 'Unauthorized',
+    serverError: 'Server error',
+    validationError: 'Please check your input',
+    printerConnectionFailed: 'Failed to connect to printer',
+    saveFailed: 'Failed to save changes',
+    deleteFailed: 'Failed to delete',
+    loadFailed: 'Failed to load data',
+  },
+
+  // Confirmations
+  confirm: {
+    delete: 'Are you sure you want to delete this?',
+    unsavedChanges: 'You have unsaved changes. Are you sure you want to leave?',
+    clearQueue: 'Are you sure you want to clear the queue?',
+  },
+};

+ 1 - 0
frontend/src/main.tsx

@@ -1,6 +1,7 @@
 import { StrictMode } from 'react'
 import { createRoot } from 'react-dom/client'
 import './index.css'
+import './i18n' // Initialize i18n
 import App from './App.tsx'
 
 createRoot(document.getElementById('root')!).render(

+ 98 - 0
frontend/src/pages/ArchivesPage.tsx

@@ -34,6 +34,7 @@ import {
   QrCode,
   Camera,
   FileText,
+  FileCode,
 } from 'lucide-react';
 import { api } from '../api/client';
 import type { Archive } from '../api/client';
@@ -103,7 +104,31 @@ function ArchiveCard({
   const [showPhotos, setShowPhotos] = useState(false);
   const [showProjectPage, setShowProjectPage] = useState(false);
   const [showSchedule, setShowSchedule] = useState(false);
+  const [showDeleteSource3mfConfirm, setShowDeleteSource3mfConfirm] = useState(false);
   const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
+  const source3mfInputRef = useRef<HTMLInputElement>(null);
+
+  const source3mfUploadMutation = useMutation({
+    mutationFn: (file: File) => api.uploadSource3mf(archive.id, file),
+    onSuccess: (data) => {
+      queryClient.invalidateQueries({ queryKey: ['archives'] });
+      showToast(`Source 3MF attached: ${data.filename}`);
+    },
+    onError: (error: Error) => {
+      showToast(error.message || 'Failed to upload source 3MF', 'error');
+    },
+  });
+
+  const source3mfDeleteMutation = useMutation({
+    mutationFn: () => api.deleteSource3mf(archive.id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['archives'] });
+      showToast('Source 3MF removed');
+    },
+    onError: (error: Error) => {
+      showToast(error.message || 'Failed to remove source 3MF', 'error');
+    },
+  });
 
   const timelapseScanMutation = useMutation({
     mutationFn: () => api.scanArchiveTimelapse(archive.id),
@@ -207,6 +232,33 @@ function ArchiveCard({
       onClick: () => timelapseScanMutation.mutate(),
       disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending,
     },
+    { label: '', divider: true, onClick: () => {} },
+    {
+      label: archive.source_3mf_path ? 'Download Source 3MF' : 'Upload Source 3MF',
+      icon: <FileCode className="w-4 h-4" />,
+      onClick: () => {
+        if (archive.source_3mf_path) {
+          const link = document.createElement('a');
+          link.href = api.getSource3mfDownloadUrl(archive.id);
+          link.download = `${archive.print_name || archive.filename}_source.3mf`;
+          link.click();
+        } else {
+          source3mfInputRef.current?.click();
+        }
+      },
+    },
+    ...(archive.source_3mf_path ? [{
+      label: 'Replace Source 3MF',
+      icon: <Upload className="w-4 h-4" />,
+      onClick: () => source3mfInputRef.current?.click(),
+    },
+    {
+      label: 'Remove Source 3MF',
+      icon: <Trash2 className="w-4 h-4" />,
+      onClick: () => setShowDeleteSource3mfConfirm(true),
+      danger: true,
+    }] : []),
+    { label: '', divider: true, onClick: () => {} },
     {
       label: 'Download',
       icon: <Download className="w-4 h-4" />,
@@ -331,6 +383,22 @@ function ArchiveCard({
             duplicate
           </div>
         )}
+        {/* Source 3MF badge */}
+        {archive.source_3mf_path && (
+          <button
+            className="absolute bottom-2 left-2 p-1.5 rounded bg-black/60 hover:bg-black/80 transition-colors"
+            onClick={(e) => {
+              e.stopPropagation();
+              // Open source 3MF in Bambu Studio - use filename in URL for slicer compatibility
+              const sourceName = (archive.print_name || archive.filename || 'source').replace(/\.gcode\.3mf$/i, '') + '_source';
+              const downloadUrl = `${window.location.origin}${api.getSource3mfForSlicer(archive.id, sourceName)}`;
+              window.location.href = `bambustudioopen://${encodeURIComponent(downloadUrl)}`;
+            }}
+            title="Open source 3MF in Bambu Studio (right-click for more options)"
+          >
+            <FileCode className="w-4 h-4 text-orange-400" />
+          </button>
+        )}
         {/* Timelapse badge */}
         {archive.timelapse_path && (
           <button
@@ -583,6 +651,21 @@ function ArchiveCard({
         />
       )}
 
+      {/* Delete Source 3MF Confirmation */}
+      {showDeleteSource3mfConfirm && (
+        <ConfirmModal
+          title="Remove Source 3MF"
+          message={`Are you sure you want to remove the source 3MF file from "${archive.print_name || archive.filename}"? This will delete the original slicer project file.`}
+          confirmText="Remove"
+          variant="danger"
+          onConfirm={() => {
+            source3mfDeleteMutation.mutate();
+            setShowDeleteSource3mfConfirm(false);
+          }}
+          onCancel={() => setShowDeleteSource3mfConfirm(false)}
+        />
+      )}
+
       {/* Context Menu */}
       {contextMenu && (
         <ContextMenu
@@ -703,6 +786,21 @@ function ArchiveCard({
           onClose={() => setShowSchedule(false)}
         />
       )}
+
+      {/* Hidden file input for source 3MF upload */}
+      <input
+        ref={source3mfInputRef}
+        type="file"
+        accept=".3mf"
+        className="hidden"
+        onChange={(e) => {
+          const file = e.target.files?.[0];
+          if (file) {
+            source3mfUploadMutation.mutate(file);
+          }
+          e.target.value = '';
+        }}
+      />
     </Card>
   );
 }

+ 616 - 0
frontend/src/pages/MaintenancePage.tsx

@@ -0,0 +1,616 @@
+import { useState } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import {
+  Wrench,
+  Loader2,
+  Check,
+  AlertTriangle,
+  Clock,
+  Plus,
+  Trash2,
+  Settings2,
+  ChevronDown,
+  ChevronUp,
+  RotateCcw,
+  Droplet,
+  Flame,
+  Ruler,
+  Sparkles,
+  Square,
+  Cable,
+  Edit3,
+} from 'lucide-react';
+import { api } from '../api/client';
+import type { MaintenanceStatus, PrinterMaintenanceOverview } from '../api/client';
+import { Card, CardContent } from '../components/Card';
+import { Button } from '../components/Button';
+import { Toggle } from '../components/Toggle';
+import { useToast } from '../contexts/ToastContext';
+
+// Icon mapping for maintenance types
+const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
+  Droplet,
+  Flame,
+  Ruler,
+  Sparkles,
+  Square,
+  Cable,
+  Wrench,
+};
+
+function getIcon(iconName: string | null) {
+  if (!iconName) return Wrench;
+  return iconMap[iconName] || Wrench;
+}
+
+function formatHours(hours: number): string {
+  if (hours < 1) {
+    return `${Math.round(hours * 60)}m`;
+  }
+  return `${hours.toFixed(1)}h`;
+}
+
+function formatHoursLong(hours: number): string {
+  const h = Math.floor(hours);
+  const m = Math.round((hours - h) * 60);
+  if (h === 0) {
+    return `${m} minutes`;
+  }
+  if (m === 0) {
+    return `${h} hours`;
+  }
+  return `${h}h ${m}m`;
+}
+
+// Simple row for a maintenance item
+function MaintenanceRow({
+  item,
+  onPerform,
+  onToggle,
+}: {
+  item: MaintenanceStatus;
+  onPerform: (id: number) => void;
+  onToggle: (id: number, enabled: boolean) => void;
+}) {
+  const Icon = getIcon(item.maintenance_type_icon);
+
+  const progressPercent = Math.max(0, Math.min(100,
+    ((item.interval_hours - item.hours_until_due) / item.interval_hours) * 100
+  ));
+
+  const getStatusColor = () => {
+    if (!item.enabled) return 'text-bambu-gray';
+    if (item.is_due) return 'text-red-400';
+    if (item.is_warning) return 'text-yellow-400';
+    return 'text-bambu-green';
+  };
+
+  const getProgressColor = () => {
+    if (!item.enabled) return 'bg-bambu-gray/30';
+    if (item.is_due) return 'bg-red-500';
+    if (item.is_warning) return 'bg-yellow-500';
+    return 'bg-bambu-green';
+  };
+
+  const getStatusText = () => {
+    if (!item.enabled) return 'Disabled';
+    if (item.is_due) return `Overdue by ${formatHours(Math.abs(item.hours_until_due))}`;
+    if (item.is_warning) return `Due in ${formatHours(item.hours_until_due)}`;
+    return `${formatHours(item.hours_until_due)} left`;
+  };
+
+  return (
+    <div className={`flex items-center gap-4 p-3 rounded-lg ${
+      item.is_due ? 'bg-red-500/10' :
+      item.is_warning ? 'bg-yellow-500/10' :
+      'bg-bambu-dark'
+    }`}>
+      {/* Icon & Name */}
+      <div className="flex items-center gap-3 min-w-[180px]">
+        <Icon className={`w-4 h-4 ${getStatusColor()}`} />
+        <span className={`text-sm ${item.enabled ? 'text-white' : 'text-bambu-gray'}`}>
+          {item.maintenance_type_name}
+        </span>
+      </div>
+
+      {/* Progress bar */}
+      <div className="flex-1 max-w-[200px]">
+        <div className="w-full h-1.5 bg-bambu-dark-tertiary rounded-full overflow-hidden">
+          <div
+            className={`h-full transition-all ${getProgressColor()}`}
+            style={{ width: `${progressPercent}%` }}
+          />
+        </div>
+      </div>
+
+      {/* Status */}
+      <div className={`text-xs min-w-[120px] ${getStatusColor()}`}>
+        {item.is_due && <AlertTriangle className="w-3 h-3 inline mr-1" />}
+        {item.is_warning && <Clock className="w-3 h-3 inline mr-1" />}
+        {!item.is_due && !item.is_warning && item.enabled && <Check className="w-3 h-3 inline mr-1" />}
+        {getStatusText()}
+      </div>
+
+      {/* Enable/Disable toggle */}
+      <Toggle
+        checked={item.enabled}
+        onChange={(checked) => onToggle(item.id, checked)}
+      />
+
+      {/* Reset button */}
+      <Button
+        size="sm"
+        variant={item.is_due ? 'primary' : 'secondary'}
+        onClick={() => onPerform(item.id)}
+        disabled={!item.enabled}
+        className="min-w-[70px]"
+      >
+        <RotateCcw className="w-3 h-3" />
+        Done
+      </Button>
+    </div>
+  );
+}
+
+// Printer section
+function PrinterSection({
+  overview,
+  onPerform,
+  onToggle,
+  onSetHours,
+}: {
+  overview: PrinterMaintenanceOverview;
+  onPerform: (id: number) => void;
+  onToggle: (id: number, enabled: boolean) => void;
+  onSetHours: (printerId: number, hours: number) => void;
+}) {
+  const [expanded, setExpanded] = useState(false);
+  const [editingHours, setEditingHours] = useState(false);
+  const [hoursInput, setHoursInput] = useState(overview.total_print_hours.toFixed(1));
+
+  // Sort items: first by maintenance_type_id for consistency, then by urgency
+  const sortedItems = [...overview.maintenance_items].sort((a, b) => {
+    // Primary sort by maintenance type ID for consistent ordering across printers
+    return a.maintenance_type_id - b.maintenance_type_id;
+  });
+
+  // Find the next upcoming task (most urgent enabled item)
+  const nextTask = [...overview.maintenance_items]
+    .filter(item => item.enabled)
+    .sort((a, b) => {
+      // Sort by urgency: overdue first, then warnings, then by hours until due
+      if (a.is_due && !b.is_due) return -1;
+      if (!a.is_due && b.is_due) return 1;
+      if (a.is_warning && !b.is_warning) return -1;
+      if (!a.is_warning && b.is_warning) return 1;
+      return a.hours_until_due - b.hours_until_due;
+    })[0];
+
+  const handleSaveHours = () => {
+    const hours = parseFloat(hoursInput);
+    if (!isNaN(hours) && hours >= 0) {
+      onSetHours(overview.printer_id, hours);
+      setEditingHours(false);
+    }
+  };
+
+  return (
+    <Card>
+      <div className="p-4">
+        {/* Header row with printer name and status */}
+        <div className="flex items-center justify-between mb-4">
+          <div className="flex items-center gap-3">
+            <h2 className="text-lg font-semibold text-white">{overview.printer_name}</h2>
+            {overview.due_count > 0 && (
+              <span className="px-2.5 py-1 bg-red-500/20 text-red-400 text-xs font-medium rounded-full flex items-center gap-1">
+                <AlertTriangle className="w-3 h-3" />
+                {overview.due_count} overdue
+              </span>
+            )}
+            {overview.warning_count > 0 && (
+              <span className="px-2.5 py-1 bg-yellow-500/20 text-yellow-400 text-xs font-medium rounded-full flex items-center gap-1">
+                <Clock className="w-3 h-3" />
+                {overview.warning_count} due soon
+              </span>
+            )}
+            {overview.due_count === 0 && overview.warning_count === 0 && (
+              <span className="px-2.5 py-1 bg-bambu-green/20 text-bambu-green text-xs font-medium rounded-full flex items-center gap-1">
+                <Check className="w-3 h-3" />
+                All good
+              </span>
+            )}
+          </div>
+          <button
+            onClick={() => setExpanded(!expanded)}
+            className="flex items-center gap-1 px-3 py-1.5 text-sm text-bambu-gray hover:text-white hover:bg-bambu-dark rounded transition-colors"
+          >
+            {expanded ? (
+              <>
+                <ChevronUp className="w-4 h-4" />
+                Hide
+              </>
+            ) : (
+              <>
+                <ChevronDown className="w-4 h-4" />
+                Details
+              </>
+            )}
+          </button>
+        </div>
+
+        {/* Info cards row */}
+        <div className="grid grid-cols-2 gap-3">
+          {/* Print Hours Card */}
+          <div className="p-3 bg-bambu-dark rounded-lg">
+            <div className="text-xs text-bambu-gray mb-1 uppercase tracking-wide">Total Print Time</div>
+            {editingHours ? (
+              <div className="flex items-center gap-2">
+                <input
+                  type="number"
+                  value={hoursInput}
+                  onChange={(e) => setHoursInput(e.target.value)}
+                  onKeyDown={(e) => {
+                    if (e.key === 'Enter') handleSaveHours();
+                    if (e.key === 'Escape') setEditingHours(false);
+                  }}
+                  className="w-20 px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-lg font-semibold"
+                  min="0"
+                  step="1"
+                  autoFocus
+                />
+                <span className="text-sm text-bambu-gray">hours</span>
+                <div className="flex gap-1 ml-auto">
+                  <Button size="sm" onClick={handleSaveHours}>Save</Button>
+                  <Button size="sm" variant="secondary" onClick={() => setEditingHours(false)}>✕</Button>
+                </div>
+              </div>
+            ) : (
+              <button
+                onClick={() => {
+                  setHoursInput(Math.round(overview.total_print_hours).toString());
+                  setEditingHours(true);
+                }}
+                className="flex items-center gap-2 group"
+                title="Click to edit total print hours"
+              >
+                <span className="text-xl font-semibold text-white group-hover:text-bambu-green transition-colors">
+                  {formatHoursLong(overview.total_print_hours)}
+                </span>
+                <Edit3 className="w-4 h-4 text-bambu-gray group-hover:text-bambu-green transition-colors" />
+              </button>
+            )}
+          </div>
+
+          {/* Next Maintenance Card */}
+          <div className={`p-3 rounded-lg ${
+            nextTask?.is_due ? 'bg-red-500/10' :
+            nextTask?.is_warning ? 'bg-yellow-500/10' :
+            'bg-bambu-dark'
+          }`}>
+            <div className="text-xs text-bambu-gray mb-1 uppercase tracking-wide">Next Maintenance</div>
+            {nextTask ? (
+              <div>
+                <div className={`text-lg font-semibold ${
+                  nextTask.is_due ? 'text-red-400' :
+                  nextTask.is_warning ? 'text-yellow-400' :
+                  'text-white'
+                }`}>
+                  {nextTask.maintenance_type_name}
+                </div>
+                <div className={`text-sm ${
+                  nextTask.is_due ? 'text-red-400' :
+                  nextTask.is_warning ? 'text-yellow-400' :
+                  'text-bambu-gray'
+                }`}>
+                  {nextTask.is_due ? (
+                    <>Overdue by {formatHours(Math.abs(nextTask.hours_until_due))}</>
+                  ) : (
+                    <>Due in {formatHours(nextTask.hours_until_due)}</>
+                  )}
+                </div>
+              </div>
+            ) : (
+              <div className="text-white">No tasks enabled</div>
+            )}
+          </div>
+        </div>
+      </div>
+
+      {expanded && (
+        <CardContent className="pt-0 space-y-2 border-t border-bambu-dark-tertiary mt-4">
+          <div className="pt-4">
+            {sortedItems.map((item) => (
+              <MaintenanceRow
+                key={item.id}
+                item={item}
+                onPerform={onPerform}
+                onToggle={onToggle}
+              />
+            ))}
+          </div>
+        </CardContent>
+      )}
+    </Card>
+  );
+}
+
+// Settings modal for managing custom types
+function SettingsModal({
+  onClose,
+  types,
+  onAddType,
+  onDeleteType,
+}: {
+  onClose: () => void;
+  types: Array<{ id: number; name: string; default_interval_hours: number; icon: string | null; is_system: boolean }>;
+  onAddType: (data: { name: string; description?: string; default_interval_hours: number; icon?: string }) => void;
+  onDeleteType: (id: number) => void;
+}) {
+  const [name, setName] = useState('');
+  const [interval, setInterval] = useState('100');
+  const [icon, setIcon] = useState('Wrench');
+
+  const handleSubmit = (e: React.FormEvent) => {
+    e.preventDefault();
+    if (name.trim() && parseFloat(interval) > 0) {
+      onAddType({
+        name: name.trim(),
+        default_interval_hours: parseFloat(interval),
+        icon,
+      });
+      setName('');
+      setInterval('100');
+    }
+  };
+
+  const customTypes = types.filter(t => !t.is_system);
+
+  return (
+    <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
+      <div className="bg-bambu-dark-secondary rounded-lg p-6 w-full max-w-lg" onClick={e => e.stopPropagation()}>
+        <h3 className="text-lg font-semibold text-white mb-4">Maintenance Settings</h3>
+
+        {/* Existing custom types */}
+        {customTypes.length > 0 && (
+          <div className="mb-6">
+            <h4 className="text-sm text-bambu-gray mb-2">Custom Maintenance Types</h4>
+            <div className="space-y-2">
+              {customTypes.map((type) => {
+                const Icon = getIcon(type.icon);
+                return (
+                  <div key={type.id} className="flex items-center justify-between p-2 bg-bambu-dark rounded">
+                    <div className="flex items-center gap-2">
+                      <Icon className="w-4 h-4 text-bambu-gray" />
+                      <span className="text-white text-sm">{type.name}</span>
+                      <span className="text-bambu-gray text-xs">({type.default_interval_hours}h)</span>
+                    </div>
+                    <button
+                      onClick={() => {
+                        if (confirm(`Delete "${type.name}"?`)) {
+                          onDeleteType(type.id);
+                        }
+                      }}
+                      className="p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-red-400"
+                    >
+                      <Trash2 className="w-4 h-4" />
+                    </button>
+                  </div>
+                );
+              })}
+            </div>
+          </div>
+        )}
+
+        {/* Add new type */}
+        <form onSubmit={handleSubmit}>
+          <h4 className="text-sm text-bambu-gray mb-2">Add Custom Type</h4>
+          <div className="flex gap-2 mb-3">
+            <input
+              type="text"
+              value={name}
+              onChange={(e) => setName(e.target.value)}
+              className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm"
+              placeholder="Name (e.g., Replace HEPA Filter)"
+            />
+            <input
+              type="number"
+              value={interval}
+              onChange={(e) => setInterval(e.target.value)}
+              className="w-20 px-2 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm"
+              placeholder="Hours"
+              min="1"
+            />
+          </div>
+          <div className="flex items-center justify-between">
+            <div className="flex gap-1">
+              {Object.keys(iconMap).map((iconName) => {
+                const IconComp = iconMap[iconName];
+                return (
+                  <button
+                    key={iconName}
+                    type="button"
+                    onClick={() => setIcon(iconName)}
+                    className={`p-1.5 rounded ${
+                      icon === iconName
+                        ? 'bg-bambu-green text-white'
+                        : 'bg-bambu-dark text-bambu-gray hover:text-white'
+                    }`}
+                  >
+                    <IconComp className="w-4 h-4" />
+                  </button>
+                );
+              })}
+            </div>
+            <Button type="submit" size="sm" disabled={!name.trim()}>
+              <Plus className="w-4 h-4" />
+              Add
+            </Button>
+          </div>
+        </form>
+
+        <div className="mt-6 pt-4 border-t border-bambu-dark-tertiary flex justify-end">
+          <Button variant="secondary" onClick={onClose}>
+            Close
+          </Button>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+export function MaintenancePage() {
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const [showSettings, setShowSettings] = useState(false);
+
+  const { data: overview, isLoading } = useQuery({
+    queryKey: ['maintenanceOverview'],
+    queryFn: api.getMaintenanceOverview,
+  });
+
+  const { data: types } = useQuery({
+    queryKey: ['maintenanceTypes'],
+    queryFn: api.getMaintenanceTypes,
+  });
+
+  const performMutation = useMutation({
+    mutationFn: ({ id, notes }: { id: number; notes?: string }) =>
+      api.performMaintenance(id, notes),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
+      queryClient.invalidateQueries({ queryKey: ['maintenanceSummary'] });
+      showToast('Maintenance marked as done');
+    },
+    onError: (error: Error) => {
+      showToast(error.message, 'error');
+    },
+  });
+
+  const updateMutation = useMutation({
+    mutationFn: ({ id, data }: { id: number; data: { custom_interval_hours?: number | null; enabled?: boolean } }) =>
+      api.updateMaintenanceItem(id, data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
+    },
+    onError: (error: Error) => {
+      showToast(error.message, 'error');
+    },
+  });
+
+  const addTypeMutation = useMutation({
+    mutationFn: api.createMaintenanceType,
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['maintenanceTypes'] });
+      queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
+      showToast('Maintenance type added');
+    },
+    onError: (error: Error) => {
+      showToast(error.message, 'error');
+    },
+  });
+
+  const deleteTypeMutation = useMutation({
+    mutationFn: api.deleteMaintenanceType,
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['maintenanceTypes'] });
+      queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
+      showToast('Maintenance type deleted');
+    },
+    onError: (error: Error) => {
+      showToast(error.message, 'error');
+    },
+  });
+
+  const setHoursMutation = useMutation({
+    mutationFn: ({ printerId, hours }: { printerId: number; hours: number }) =>
+      api.setPrinterHours(printerId, hours),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
+      queryClient.invalidateQueries({ queryKey: ['maintenanceSummary'] });
+      showToast('Print hours updated');
+    },
+    onError: (error: Error) => {
+      showToast(error.message, 'error');
+    },
+  });
+
+  const handlePerform = (id: number) => {
+    performMutation.mutate({ id });
+  };
+
+  const handleToggle = (id: number, enabled: boolean) => {
+    updateMutation.mutate({ id, data: { enabled } });
+  };
+
+  const handleSetHours = (printerId: number, hours: number) => {
+    setHoursMutation.mutate({ printerId, hours });
+  };
+
+  if (isLoading) {
+    return (
+      <div className="p-8 flex justify-center">
+        <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+      </div>
+    );
+  }
+
+  // Calculate totals
+  const totalDue = overview?.reduce((sum, p) => sum + p.due_count, 0) || 0;
+  const totalWarning = overview?.reduce((sum, p) => sum + p.warning_count, 0) || 0;
+
+  return (
+    <div className="p-8">
+      {/* Header */}
+      <div className="mb-6 flex items-center justify-between">
+        <div>
+          <h1 className="text-2xl font-bold text-white">Maintenance</h1>
+          <p className="text-bambu-gray text-sm">
+            {totalDue > 0 && <span className="text-red-400">{totalDue} tasks overdue</span>}
+            {totalDue > 0 && totalWarning > 0 && ' · '}
+            {totalWarning > 0 && <span className="text-yellow-400">{totalWarning} due soon</span>}
+            {totalDue === 0 && totalWarning === 0 && 'All maintenance up to date'}
+          </p>
+        </div>
+        <Button variant="secondary" onClick={() => setShowSettings(true)}>
+          <Settings2 className="w-4 h-4" />
+          Settings
+        </Button>
+      </div>
+
+      {/* Printers - sorted alphabetically */}
+      <div className="space-y-4">
+        {overview && overview.length > 0 ? (
+          [...overview].sort((a, b) => a.printer_name.localeCompare(b.printer_name)).map((printerOverview) => (
+            <PrinterSection
+              key={printerOverview.printer_id}
+              overview={printerOverview}
+              onPerform={handlePerform}
+              onToggle={handleToggle}
+              onSetHours={handleSetHours}
+            />
+          ))
+        ) : (
+          <Card>
+            <CardContent className="text-center py-12">
+              <Wrench className="w-12 h-12 mx-auto mb-4 text-bambu-gray/30" />
+              <p className="text-bambu-gray">No printers configured</p>
+              <p className="text-sm text-bambu-gray/70 mt-1">
+                Add printers to start tracking maintenance
+              </p>
+            </CardContent>
+          </Card>
+        )}
+      </div>
+
+      {/* Settings modal */}
+      {showSettings && types && (
+        <SettingsModal
+          onClose={() => setShowSettings(false)}
+          types={types}
+          onAddType={(data) => addTypeMutation.mutate(data)}
+          onDeleteType={(id) => deleteTypeMutation.mutate(id)}
+        />
+      )}
+    </div>
+  );
+}

+ 65 - 2
frontend/src/pages/PrintersPage.tsx

@@ -16,7 +16,9 @@ import {
   Power,
   PowerOff,
   Zap,
+  Wrench,
 } from 'lucide-react';
+import { useNavigate } from 'react-router-dom';
 import { api } from '../api/client';
 import type { Printer, PrinterCreate } from '../api/client';
 import { Card, CardContent } from '../components/Card';
@@ -82,8 +84,22 @@ function CoverImage({ url, printName }: { url: string | null; printName?: string
   );
 }
 
-function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIfDisconnected?: boolean }) {
+interface PrinterMaintenanceInfo {
+  due_count: number;
+  warning_count: number;
+}
+
+function PrinterCard({
+  printer,
+  hideIfDisconnected,
+  maintenanceInfo
+}: {
+  printer: Printer;
+  hideIfDisconnected?: boolean;
+  maintenanceInfo?: PrinterMaintenanceInfo;
+}) {
   const queryClient = useQueryClient();
+  const navigate = useNavigate();
   const [showMenu, setShowMenu] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [showFileManager, setShowFileManager] = useState(false);
@@ -195,6 +211,29 @@ function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIf
                   : 'OK'}
               </button>
             )}
+            {/* Maintenance Status Indicator - always show */}
+            {maintenanceInfo && (
+              <button
+                onClick={() => navigate('/maintenance')}
+                className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs cursor-pointer hover:opacity-80 transition-opacity ${
+                  maintenanceInfo.due_count > 0
+                    ? 'bg-red-500/20 text-red-400'
+                    : maintenanceInfo.warning_count > 0
+                    ? 'bg-orange-500/20 text-orange-400'
+                    : 'bg-bambu-green/20 text-bambu-green'
+                }`}
+                title={
+                  maintenanceInfo.due_count > 0 || maintenanceInfo.warning_count > 0
+                    ? `${maintenanceInfo.due_count > 0 ? `${maintenanceInfo.due_count} maintenance due` : ''}${maintenanceInfo.due_count > 0 && maintenanceInfo.warning_count > 0 ? ', ' : ''}${maintenanceInfo.warning_count > 0 ? `${maintenanceInfo.warning_count} due soon` : ''} - Click to view`
+                    : 'All maintenance up to date - Click to view'
+                }
+              >
+                <Wrench className="w-3 h-3" />
+                {maintenanceInfo.due_count > 0 || maintenanceInfo.warning_count > 0
+                  ? maintenanceInfo.due_count + maintenanceInfo.warning_count
+                  : 'OK'}
+              </button>
+            )}
             <div className="relative">
               <Button
                 variant="ghost"
@@ -674,6 +713,25 @@ export function PrintersPage() {
     queryFn: api.getPrinters,
   });
 
+  // Fetch maintenance overview for all printers to show badges
+  const { data: maintenanceOverview } = useQuery({
+    queryKey: ['maintenanceOverview'],
+    queryFn: api.getMaintenanceOverview,
+    staleTime: 60 * 1000, // 1 minute
+  });
+
+  // Create a map of printer_id -> maintenance info for quick lookup
+  const maintenanceByPrinter = maintenanceOverview?.reduce(
+    (acc, overview) => {
+      acc[overview.printer_id] = {
+        due_count: overview.due_count,
+        warning_count: overview.warning_count,
+      };
+      return acc;
+    },
+    {} as Record<number, PrinterMaintenanceInfo>
+  ) || {};
+
   const addMutation = useMutation({
     mutationFn: api.createPrinter,
     onSuccess: () => {
@@ -727,7 +785,12 @@ export function PrintersPage() {
       ) : (
         <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
           {printers?.map((printer) => (
-            <PrinterCard key={printer.id} printer={printer} hideIfDisconnected={hideDisconnected} />
+            <PrinterCard
+              key={printer.id}
+              printer={printer}
+              hideIfDisconnected={hideDisconnected}
+              maintenanceInfo={maintenanceByPrinter[printer.id]}
+            />
           ))}
         </div>
       )}

+ 208 - 21
frontend/src/pages/SettingsPage.tsx

@@ -1,18 +1,22 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Save, Loader2, Check, Plus, Plug, AlertTriangle, RotateCcw, Bell } from 'lucide-react';
+import { Save, Loader2, Check, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
-import type { AppSettings, SmartPlug, NotificationProvider } from '../api/client';
+import type { AppSettings, SmartPlug, NotificationProvider, UpdateStatus } from '../api/client';
 import { Card, CardContent, CardHeader } from '../components/Card';
 import { Button } from '../components/Button';
 import { SmartPlugCard } from '../components/SmartPlugCard';
 import { AddSmartPlugModal } from '../components/AddSmartPlugModal';
 import { NotificationProviderCard } from '../components/NotificationProviderCard';
 import { AddNotificationModal } from '../components/AddNotificationModal';
+import { SpoolmanSettings } from '../components/SpoolmanSettings';
 import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
+import { availableLanguages } from '../i18n';
 import { useState, useEffect } from 'react';
 
 export function SettingsPage() {
   const queryClient = useQueryClient();
+  const { t, i18n } = useTranslation();
   const [localSettings, setLocalSettings] = useState<AppSettings | null>(null);
   const [hasChanges, setHasChanges] = useState(false);
   const [showSaved, setShowSaved] = useState(false);
@@ -52,6 +56,37 @@ export function SettingsPage() {
     queryFn: api.checkFfmpeg,
   });
 
+  const { data: versionInfo } = useQuery({
+    queryKey: ['version'],
+    queryFn: api.getVersion,
+  });
+
+  const { data: updateCheck, refetch: refetchUpdateCheck, isRefetching: isCheckingUpdate } = useQuery({
+    queryKey: ['updateCheck'],
+    queryFn: api.checkForUpdates,
+    staleTime: 5 * 60 * 1000,
+  });
+
+  const { data: updateStatus, refetch: refetchUpdateStatus } = useQuery({
+    queryKey: ['updateStatus'],
+    queryFn: api.getUpdateStatus,
+    refetchInterval: (query) => {
+      const status = query.state.data as UpdateStatus | undefined;
+      // Poll while update is in progress
+      if (status?.status === 'downloading' || status?.status === 'installing') {
+        return 1000;
+      }
+      return false;
+    },
+  });
+
+  const applyUpdateMutation = useMutation({
+    mutationFn: api.applyUpdate,
+    onSuccess: () => {
+      refetchUpdateStatus();
+    },
+  });
+
   // Sync local state when settings load
   useEffect(() => {
     if (settings && !localSettings) {
@@ -69,7 +104,9 @@ export function SettingsPage() {
         settings.default_filament_cost !== localSettings.default_filament_cost ||
         settings.currency !== localSettings.currency ||
         settings.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh ||
-        settings.energy_tracking_mode !== localSettings.energy_tracking_mode;
+        settings.energy_tracking_mode !== localSettings.energy_tracking_mode ||
+        settings.check_updates !== localSettings.check_updates ||
+        settings.notification_language !== localSettings.notification_language;
       setHasChanges(changed);
     }
   }, [settings, localSettings]);
@@ -285,12 +322,32 @@ export function SettingsPage() {
 
           <Card>
             <CardHeader>
-              <h2 className="text-lg font-semibold text-white">Interface</h2>
+              <h2 className="text-lg font-semibold text-white">{t('settings.general')}</h2>
             </CardHeader>
             <CardContent className="space-y-4">
               <div>
                 <label className="block text-sm text-bambu-gray mb-1">
-                  Default view on startup
+                  <Globe className="w-4 h-4 inline mr-1" />
+                  {t('settings.language')}
+                </label>
+                <select
+                  value={i18n.language}
+                  onChange={(e) => i18n.changeLanguage(e.target.value)}
+                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                >
+                  {availableLanguages.map((lang) => (
+                    <option key={lang.code} value={lang.code}>
+                      {lang.nativeName} ({lang.name})
+                    </option>
+                  ))}
+                </select>
+                <p className="text-xs text-bambu-gray mt-1">
+                  {t('settings.languageDescription')}
+                </p>
+              </div>
+              <div>
+                <label className="block text-sm text-bambu-gray mb-1">
+                  {t('settings.defaultView')}
                 </label>
                 <select
                   value={defaultView}
@@ -299,12 +356,12 @@ export function SettingsPage() {
                 >
                   {defaultNavItems.map((item) => (
                     <option key={item.id} value={item.to}>
-                      {item.label}
+                      {t(item.labelKey)}
                     </option>
                   ))}
                 </select>
                 <p className="text-xs text-bambu-gray mt-1">
-                  Page to show when opening the app
+                  {t('settings.defaultViewDescription')}
                 </p>
               </div>
               <div className="flex items-center justify-between">
@@ -325,27 +382,137 @@ export function SettingsPage() {
               </div>
             </CardContent>
           </Card>
+        </div>
+
+        {/* Second Column - Spoolman & Updates */}
+        <div className="space-y-6 flex-1 max-w-md">
+          <SpoolmanSettings />
 
           <Card>
             <CardHeader>
-              <h2 className="text-lg font-semibold text-white">About</h2>
+              <h2 className="text-lg font-semibold text-white">Updates</h2>
             </CardHeader>
-            <CardContent>
-              <div className="space-y-2 text-sm">
-                <p className="text-white">Bambusy v0.1.2</p>
-                <p className="text-bambu-gray">
-                  Archive and manage your Bambu Lab 3MF files
-                </p>
-                <p className="text-bambu-gray">
-                  Connect to printers via LAN mode (developer mode required)
-                </p>
+            <CardContent className="space-y-4">
+              <div className="flex items-center justify-between">
+                <div>
+                  <p className="text-white">Check for updates</p>
+                  <p className="text-sm text-bambu-gray">
+                    Automatically check for new versions on startup
+                  </p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={localSettings.check_updates}
+                    onChange={(e) => updateSetting('check_updates', 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>
+
+              <div className="border-t border-bambu-dark-tertiary pt-4">
+                <div className="flex items-center justify-between mb-2">
+                  <div>
+                    <p className="text-white">Current version</p>
+                    <p className="text-sm text-bambu-gray">v{versionInfo?.version || '...'}</p>
+                  </div>
+                  <Button
+                    variant="secondary"
+                    size="sm"
+                    onClick={() => refetchUpdateCheck()}
+                    disabled={isCheckingUpdate}
+                  >
+                    {isCheckingUpdate ? (
+                      <Loader2 className="w-4 h-4 animate-spin" />
+                    ) : (
+                      <RefreshCw className="w-4 h-4" />
+                    )}
+                    Check now
+                  </Button>
+                </div>
+
+                {updateCheck?.update_available ? (
+                  <div className="mt-4 p-3 bg-bambu-green/10 border border-bambu-green/30 rounded-lg">
+                    <div className="flex items-start justify-between">
+                      <div>
+                        <p className="text-bambu-green font-medium">
+                          Update available: v{updateCheck.latest_version}
+                        </p>
+                        {updateCheck.release_name && updateCheck.release_name !== updateCheck.latest_version && (
+                          <p className="text-sm text-bambu-gray mt-1">{updateCheck.release_name}</p>
+                        )}
+                        {updateCheck.release_notes && (
+                          <p className="text-sm text-bambu-gray mt-2 whitespace-pre-line line-clamp-3">
+                            {updateCheck.release_notes}
+                          </p>
+                        )}
+                      </div>
+                      {updateCheck.release_url && (
+                        <a
+                          href={updateCheck.release_url}
+                          target="_blank"
+                          rel="noopener noreferrer"
+                          className="text-bambu-gray hover:text-white transition-colors"
+                          title="View release on GitHub"
+                        >
+                          <ExternalLink className="w-4 h-4" />
+                        </a>
+                      )}
+                    </div>
+
+                    {updateStatus?.status === 'downloading' || updateStatus?.status === 'installing' ? (
+                      <div className="mt-3">
+                        <div className="flex items-center gap-2 text-sm text-bambu-gray">
+                          <Loader2 className="w-4 h-4 animate-spin" />
+                          <span>{updateStatus.message}</span>
+                        </div>
+                        <div className="mt-2 w-full bg-bambu-dark-tertiary rounded-full h-2">
+                          <div
+                            className="bg-bambu-green h-2 rounded-full transition-all duration-300"
+                            style={{ width: `${updateStatus.progress}%` }}
+                          />
+                        </div>
+                      </div>
+                    ) : updateStatus?.status === 'complete' ? (
+                      <div className="mt-3 p-2 bg-bambu-green/20 rounded text-sm text-bambu-green">
+                        {updateStatus.message}
+                      </div>
+                    ) : updateStatus?.status === 'error' ? (
+                      <div className="mt-3 p-2 bg-red-500/20 rounded text-sm text-red-400">
+                        {updateStatus.error || updateStatus.message}
+                      </div>
+                    ) : (
+                      <Button
+                        className="mt-3"
+                        onClick={() => applyUpdateMutation.mutate()}
+                        disabled={applyUpdateMutation.isPending}
+                      >
+                        {applyUpdateMutation.isPending ? (
+                          <Loader2 className="w-4 h-4 animate-spin" />
+                        ) : (
+                          <Download className="w-4 h-4" />
+                        )}
+                        Install Update
+                      </Button>
+                    )}
+                  </div>
+                ) : updateCheck?.error ? (
+                  <div className="mt-2 p-2 bg-red-500/10 border border-red-500/30 rounded text-sm text-red-400">
+                    Failed to check for updates: {updateCheck.error}
+                  </div>
+                ) : updateCheck && !updateCheck.update_available ? (
+                  <p className="mt-2 text-sm text-bambu-gray">
+                    You're running the latest version
+                  </p>
+                ) : null}
               </div>
             </CardContent>
           </Card>
         </div>
 
-        {/* Middle Column - Smart Plugs */}
-        <div className="w-96 flex-shrink-0">
+        {/* Third Column - Smart Plugs */}
+        <div className="w-80 flex-shrink-0">
           <Card>
             <CardHeader>
               <div className="flex items-center justify-between">
@@ -397,8 +564,8 @@ export function SettingsPage() {
           </Card>
         </div>
 
-        {/* Right Column - Notifications */}
-        <div className="w-96 flex-shrink-0">
+        {/* Fourth Column - Notifications */}
+        <div className="w-80 flex-shrink-0">
           <Card>
             <CardHeader>
               <div className="flex items-center justify-between">
@@ -422,6 +589,26 @@ export function SettingsPage() {
               <p className="text-sm text-bambu-gray mb-4">
                 Get notified about print events via WhatsApp, Telegram, Email, and more.
               </p>
+
+              {/* Notification Language */}
+              <div className="flex items-center justify-between py-3 border-b border-bambu-dark-tertiary mb-4">
+                <div>
+                  <p className="text-white">{t('settings.notificationLanguage')}</p>
+                  <p className="text-sm text-bambu-gray">{t('settings.notificationLanguageDescription')}</p>
+                </div>
+                <select
+                  value={localSettings.notification_language || 'en'}
+                  onChange={(e) => updateSetting('notification_language', e.target.value)}
+                  className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-bambu-green"
+                >
+                  {availableLanguages.map((lang) => (
+                    <option key={lang.code} value={lang.code}>
+                      {lang.nativeName}
+                    </option>
+                  ))}
+                </select>
+              </div>
+
               {providersLoading ? (
                 <div className="flex justify-center py-8">
                   <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />

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


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


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


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


+ 2 - 2
static/index.html

@@ -7,8 +7,8 @@
     <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
     <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
     <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
-    <script type="module" crossorigin src="/assets/index-C2974G18.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DJNRCg8M.css">
+    <script type="module" crossorigin src="/assets/index-CKBzHHT0.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-H_ymON9v.css">
   </head>
   <body>
     <div id="root"></div>

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