Browse Source

Added notification module

Martin Ziegler 5 months ago
parent
commit
17c37a7ccd

+ 186 - 0
PLAN.md

@@ -0,0 +1,186 @@
+# Notifications Feature Implementation Plan
+
+## Overview
+Add push notifications for print events (start, complete, fail) with support for multiple notification providers.
+
+## Supported Providers (Initial Release)
+1. **CallMeBot/WhatsApp** - Free, uses HTTP API with phone number + API key
+2. **ntfy** - Self-hosted or ntfy.sh, simple HTTP POST
+3. **Pushover** - Commercial ($5 one-time), HTTP API with user key + app token
+4. **Telegram** - Free bot API, requires bot token + chat ID
+5. **Email (SMTP)** - Universal fallback
+
+## Database Design
+
+### New Table: `notification_providers`
+```sql
+CREATE TABLE notification_providers (
+    id INTEGER PRIMARY KEY,
+    name TEXT NOT NULL,                    -- User-defined name ("My WhatsApp")
+    provider_type TEXT NOT NULL,           -- "callmebot", "ntfy", "pushover", "telegram", "email"
+    enabled BOOLEAN DEFAULT true,
+
+    -- Provider-specific config (JSON or individual fields)
+    config TEXT NOT NULL,                  -- JSON: {"phone": "+1234", "apikey": "xxx"}
+
+    -- Event triggers (which events send notifications)
+    on_print_start BOOLEAN DEFAULT false,
+    on_print_complete BOOLEAN DEFAULT true,
+    on_print_failed BOOLEAN DEFAULT true,
+
+    -- Optional: Link to specific printer (NULL = all printers)
+    printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL,
+
+    -- Timestamps
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+```
+
+### Config JSON Structure by Provider
+```python
+# CallMeBot/WhatsApp
+{"phone": "+1234567890", "apikey": "123456"}
+
+# ntfy
+{"server": "https://ntfy.sh", "topic": "my-printer", "auth_token": "optional"}
+
+# Pushover
+{"user_key": "xxx", "app_token": "yyy", "priority": 0}
+
+# Telegram
+{"bot_token": "123:ABC", "chat_id": "12345678"}
+
+# Email (SMTP)
+{"smtp_server": "smtp.gmail.com", "smtp_port": 587, "username": "x", "password": "y", "from_email": "x@gmail.com", "to_email": "dest@example.com"}
+```
+
+## Backend Implementation
+
+### 1. Model: `backend/app/models/notification.py`
+- SQLAlchemy model for `notification_providers` table
+- Relationship to Printer (optional, nullable)
+
+### 2. Schema: `backend/app/schemas/notification.py`
+- `NotificationProviderBase` - Common fields
+- `NotificationProviderCreate` - For creating new providers
+- `NotificationProviderUpdate` - For partial updates
+- `NotificationProviderResponse` - API response with id/timestamps
+- `NotificationTestRequest` - For testing notifications
+
+### 3. Service: `backend/app/services/notification_service.py`
+Core notification dispatcher with provider implementations:
+
+```python
+class NotificationService:
+    async def send_notification(self, provider: NotificationProvider, event: str, data: dict) -> bool
+    async def on_print_start(self, printer_id: int, data: dict, db: AsyncSession)
+    async def on_print_complete(self, printer_id: int, status: str, data: dict, db: AsyncSession)
+
+    # Provider-specific methods
+    async def _send_callmebot(self, config: dict, message: str) -> bool
+    async def _send_ntfy(self, config: dict, title: str, message: str) -> bool
+    async def _send_pushover(self, config: dict, title: str, message: str) -> bool
+    async def _send_telegram(self, config: dict, message: str) -> bool
+    async def _send_email(self, config: dict, subject: str, body: str) -> bool
+```
+
+### 4. Routes: `backend/app/api/routes/notifications.py`
+```
+GET    /notifications/              - List all providers
+POST   /notifications/              - Create provider
+GET    /notifications/{id}          - Get provider details
+PATCH  /notifications/{id}          - Update provider
+DELETE /notifications/{id}          - Delete provider
+POST   /notifications/{id}/test     - Send test notification
+POST   /notifications/test-config   - Test config before saving
+```
+
+### 5. Integration in `main.py`
+Add calls to notification service in existing event handlers:
+- `on_print_start()` - After smart_plug_manager call (line ~244)
+- `on_print_complete()` - After archive update (line ~580)
+
+## Frontend Implementation
+
+### 1. API Client: `frontend/src/api/client.ts`
+Add types and API methods for notification providers.
+
+### 2. Components
+- `NotificationProviderCard.tsx` - Display single provider with enable/disable toggle
+- `AddNotificationModal.tsx` - Modal for adding/editing providers with provider-specific forms
+
+### 3. Settings Page Integration
+Add "Notifications" section in SettingsPage.tsx (similar to Smart Plugs section):
+- List of configured providers
+- Add button
+- Per-provider enable/disable
+- Test button
+- Event toggles (start/complete/failed)
+
+## Message Templates
+
+### Print Started
+```
+🖨️ Print Started
+{printer_name}: {filename}
+Estimated time: {est_time}
+```
+
+### Print Completed
+```
+✅ Print Completed
+{printer_name}: {filename}
+Time: {actual_time}
+Filament: {filament_used}g
+```
+
+### Print Failed
+```
+❌ Print Failed
+{printer_name}: {filename}
+Status: {failure_reason}
+Progress: {progress}%
+```
+
+## Implementation Order
+
+### Phase 1: Backend Core
+1. Create notification model with migrations
+2. Create notification schema
+3. Create notification service with all 5 providers
+4. Create notification routes (CRUD + test)
+5. Register routes in main.py
+6. Integrate into print event handlers
+
+### Phase 2: Frontend
+7. Add API types and methods
+8. Create NotificationProviderCard component
+9. Create AddNotificationModal component
+10. Add Notifications section to SettingsPage
+
+### Phase 3: Testing & Polish
+11. Test each provider
+12. Add error handling and logging
+13. Handle network failures gracefully (don't block print events)
+
+## Technical Notes
+
+### Async HTTP Requests
+Use `httpx` (already available) for async HTTP calls to notification APIs.
+
+### Error Handling
+- Notifications should NEVER block print events
+- Log failures but continue processing
+- Store last_error and last_success timestamps for UI feedback
+
+### Security
+- Store credentials in database (SQLite file already contains access codes)
+- Consider encryption for sensitive fields in future
+
+### Rate Limiting
+- Debounce rapid events (don't spam on quick start/stop cycles)
+- Consider per-provider rate limits
+
+## Questions for User
+None - proceeding with the 5 providers as discussed.

+ 95 - 1
README.md

@@ -79,6 +79,15 @@ v∆v
   - Search by profile name or filament
   - Dual-nozzle support for H2 series (auto-detected from MQTT)
   - Left/Right extruder column layout for dual-nozzle printers
+- **Push Notifications** - Get notified about print events via multiple channels:
+  - WhatsApp (via CallMeBot)
+  - ntfy (self-hosted or ntfy.sh)
+  - Pushover
+  - Telegram
+  - Email (SMTP with TLS/SSL/plain options)
+  - Configurable event triggers (start, complete, failed, stopped, progress milestones)
+  - Quiet hours to suppress notifications during sleep
+  - Per-printer filtering
 - **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
@@ -528,6 +537,91 @@ Each plug card shows:
 - On/Off buttons for manual control
 - Expandable settings panel
 
+### Push Notifications
+
+Bambusy can send push notifications when print events occur. Notifications are useful for monitoring prints remotely without checking the app constantly.
+
+#### Supported Providers
+
+| Provider | Description | Setup Required |
+|----------|-------------|----------------|
+| **WhatsApp** | Via [CallMeBot](https://www.callmebot.com/blog/free-api-whatsapp-messages/) | Free API key from CallMeBot |
+| **ntfy** | Self-hosted or [ntfy.sh](https://ntfy.sh) | Just a topic name (no account needed for public server) |
+| **Pushover** | [Pushover](https://pushover.net/) push notifications | Pushover account + app token |
+| **Telegram** | Via Telegram Bot | Bot token from @BotFather |
+| **Email** | SMTP email | SMTP server credentials |
+
+#### Adding a Notification Provider
+
+1. Go to **Settings** > **Notifications**
+2. Click **Add Provider**
+3. Select a provider type and enter the required configuration
+4. Click **Send Test** to verify the configuration works
+5. Configure which events should trigger notifications
+6. Click **Add**
+
+#### Event Triggers
+
+Configure which events send notifications:
+
+| Event | Description |
+|-------|-------------|
+| **Print Started** | When a print job begins |
+| **Print Completed** | When a print finishes successfully |
+| **Print Failed** | When a print fails or errors out |
+| **Print Stopped** | When you manually stop/cancel a print |
+| **Progress Milestones** | At 25%, 50%, and 75% progress |
+| **Printer Offline** | When a printer disconnects |
+| **Printer Error** | When HMS errors are detected |
+| **Low Filament** | When filament is running low |
+
+#### Quiet Hours
+
+Enable quiet hours to suppress notifications during sleep or work hours:
+
+1. Enable **Quiet Hours** toggle
+2. Set start time (e.g., 22:00)
+3. Set end time (e.g., 07:00)
+
+Notifications during quiet hours are silently skipped.
+
+#### Per-Printer Filtering
+
+By default, notifications are sent for all printers. To limit notifications to a specific printer:
+
+1. Open the notification provider settings
+2. Select a printer from the **Printer** dropdown
+3. Only events from that printer will trigger notifications
+
+#### Provider Setup Guides
+
+**WhatsApp (CallMeBot):**
+1. Add CallMeBot to your contacts: +34 644 51 95 23
+2. Send "I allow callmebot to send me messages" via WhatsApp
+3. You'll receive an API key
+4. Enter your phone number (with country code) and API key in Bambusy
+
+**ntfy:**
+1. Choose a unique topic name (e.g., `my-printer-alerts-xyz123`)
+2. Subscribe to it on your phone using the ntfy app or web interface
+3. Enter the topic name in Bambusy (server defaults to ntfy.sh)
+
+**Pushover:**
+1. Create an account at [pushover.net](https://pushover.net/)
+2. Create an application to get an API token
+3. Enter your user key and app token in Bambusy
+
+**Telegram:**
+1. Message @BotFather on Telegram to create a bot
+2. Get your chat ID by messaging @userinfobot
+3. Enter the bot token and chat ID in Bambusy
+
+**Email:**
+1. Configure your SMTP server settings
+2. For Gmail, use an App Password (not your regular password)
+3. Choose security mode: STARTTLS (port 587), SSL (port 465), or None (port 25)
+4. Enable/disable authentication as needed
+
 ## Tech Stack
 
 - **Backend**: Python / FastAPI
@@ -699,8 +793,8 @@ To fix the printer's clock:
 - [x] Print scheduling and queuing
 - [x] Automatic finish photo capture
 - [x] K-Profiles management (pressure advance)
+- [x] Push notifications (WhatsApp, ntfy, Pushover, Telegram, Email)
 - [ ] Maintenance tracker
-- [ ] Notifications (email, push)
 - [ ] Mobile-optimized UI
 
 ## License

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

@@ -0,0 +1,223 @@
+"""API routes for notification providers."""
+
+import json
+import logging
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.database import get_db
+from backend.app.models.notification import NotificationProvider
+from backend.app.schemas.notification import (
+    NotificationProviderCreate,
+    NotificationProviderResponse,
+    NotificationProviderUpdate,
+    NotificationTestRequest,
+    NotificationTestResponse,
+)
+from backend.app.services.notification_service import notification_service
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/notifications", tags=["notifications"])
+
+
+def _provider_to_dict(provider: NotificationProvider) -> dict:
+    """Convert a NotificationProvider model to a response dictionary."""
+    return {
+        "id": provider.id,
+        "name": provider.name,
+        "provider_type": provider.provider_type,
+        "enabled": provider.enabled,
+        "config": json.loads(provider.config) if isinstance(provider.config, str) else provider.config,
+        # Print lifecycle events
+        "on_print_start": provider.on_print_start,
+        "on_print_complete": provider.on_print_complete,
+        "on_print_failed": provider.on_print_failed,
+        "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,
+        # Quiet hours
+        "quiet_hours_enabled": provider.quiet_hours_enabled,
+        "quiet_hours_start": provider.quiet_hours_start,
+        "quiet_hours_end": provider.quiet_hours_end,
+        # Printer filter
+        "printer_id": provider.printer_id,
+        # Status tracking
+        "last_success": provider.last_success,
+        "last_error": provider.last_error,
+        "last_error_at": provider.last_error_at,
+        # Timestamps
+        "created_at": provider.created_at,
+        "updated_at": provider.updated_at,
+    }
+
+
+@router.get("/", response_model=list[NotificationProviderResponse])
+async def list_notification_providers(db: AsyncSession = Depends(get_db)):
+    """List all notification providers."""
+    result = await db.execute(
+        select(NotificationProvider).order_by(NotificationProvider.created_at.desc())
+    )
+    providers = result.scalars().all()
+
+    return [_provider_to_dict(provider) for provider in providers]
+
+
+@router.post("/", response_model=NotificationProviderResponse)
+async def create_notification_provider(
+    provider_data: NotificationProviderCreate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Create a new notification provider."""
+    provider = NotificationProvider(
+        name=provider_data.name,
+        provider_type=provider_data.provider_type.value,
+        enabled=provider_data.enabled,
+        config=json.dumps(provider_data.config),
+        # Print lifecycle events
+        on_print_start=provider_data.on_print_start,
+        on_print_complete=provider_data.on_print_complete,
+        on_print_failed=provider_data.on_print_failed,
+        on_print_progress=provider_data.on_print_progress,
+        # Printer status events
+        on_printer_offline=provider_data.on_printer_offline,
+        on_printer_error=provider_data.on_printer_error,
+        on_filament_low=provider_data.on_filament_low,
+        # Quiet hours
+        quiet_hours_enabled=provider_data.quiet_hours_enabled,
+        quiet_hours_start=provider_data.quiet_hours_start,
+        quiet_hours_end=provider_data.quiet_hours_end,
+        # Printer filter
+        printer_id=provider_data.printer_id,
+    )
+
+    db.add(provider)
+    await db.commit()
+    await db.refresh(provider)
+
+    logger.info(f"Created notification provider: {provider.name} ({provider.provider_type})")
+
+    return _provider_to_dict(provider)
+
+
+@router.get("/{provider_id}", response_model=NotificationProviderResponse)
+async def get_notification_provider(
+    provider_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get a specific notification provider."""
+    result = await db.execute(
+        select(NotificationProvider).where(NotificationProvider.id == provider_id)
+    )
+    provider = result.scalar_one_or_none()
+
+    if not provider:
+        raise HTTPException(status_code=404, detail="Notification provider not found")
+
+    return _provider_to_dict(provider)
+
+
+@router.patch("/{provider_id}", response_model=NotificationProviderResponse)
+async def update_notification_provider(
+    provider_id: int,
+    update_data: NotificationProviderUpdate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Update a notification provider."""
+    result = await db.execute(
+        select(NotificationProvider).where(NotificationProvider.id == provider_id)
+    )
+    provider = result.scalar_one_or_none()
+
+    if not provider:
+        raise HTTPException(status_code=404, detail="Notification provider not found")
+
+    # Update only provided fields
+    update_dict = update_data.model_dump(exclude_unset=True)
+
+    for key, value in update_dict.items():
+        if key == "config" and value is not None:
+            setattr(provider, key, json.dumps(value))
+        elif key == "provider_type" and value is not None:
+            setattr(provider, key, value.value)
+        else:
+            setattr(provider, key, value)
+
+    await db.commit()
+    await db.refresh(provider)
+
+    logger.info(f"Updated notification provider: {provider.name}")
+
+    return _provider_to_dict(provider)
+
+
+@router.delete("/{provider_id}")
+async def delete_notification_provider(
+    provider_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Delete a notification provider."""
+    result = await db.execute(
+        select(NotificationProvider).where(NotificationProvider.id == provider_id)
+    )
+    provider = result.scalar_one_or_none()
+
+    if not provider:
+        raise HTTPException(status_code=404, detail="Notification provider not found")
+
+    name = provider.name
+    await db.delete(provider)
+    await db.commit()
+
+    logger.info(f"Deleted notification provider: {name}")
+
+    return {"message": f"Notification provider '{name}' deleted"}
+
+
+@router.post("/{provider_id}/test", response_model=NotificationTestResponse)
+async def test_notification_provider(
+    provider_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Send a test notification using an existing provider."""
+    result = await db.execute(
+        select(NotificationProvider).where(NotificationProvider.id == provider_id)
+    )
+    provider = result.scalar_one_or_none()
+
+    if not provider:
+        raise HTTPException(status_code=404, detail="Notification provider not found")
+
+    config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
+    success, message = await notification_service.send_test_notification(
+        provider.provider_type, config
+    )
+
+    # Update provider status
+    if success:
+        from datetime import datetime
+        provider.last_success = datetime.utcnow()
+    else:
+        from datetime import datetime
+        provider.last_error = message
+        provider.last_error_at = datetime.utcnow()
+
+    await db.commit()
+
+    return NotificationTestResponse(success=success, message=message)
+
+
+@router.post("/test-config", response_model=NotificationTestResponse)
+async def test_notification_config(
+    test_request: NotificationTestRequest,
+):
+    """Test notification configuration before saving."""
+    success, message = await notification_service.send_test_notification(
+        test_request.provider_type.value, test_request.config
+    )
+
+    return NotificationTestResponse(success=success, message=message)

+ 10 - 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  # noqa: F401
+    from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification  # noqa: F401
 
     async with engine.begin() as conn:
         await conn.run_sync(Base.metadata.create_all)
@@ -73,3 +73,12 @@ async def run_migrations(conn):
     except Exception:
         # Column already exists
         pass
+
+    # Migration: Add on_print_stopped column to notification_providers
+    try:
+        await conn.execute(text(
+            "ALTER TABLE notification_providers ADD COLUMN on_print_stopped BOOLEAN DEFAULT 1"
+        ))
+    except Exception:
+        # Column already exists
+        pass

+ 36 - 1
backend/app/main.py

@@ -54,8 +54,9 @@ 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
+from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications
 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 (
     printer_manager,
     printer_state_to_dict,
@@ -431,6 +432,20 @@ async def on_print_start(printer_id: int, data: dict):
         import logging
         logging.getLogger(__name__).warning(f"Smart plug on_print_start failed: {e}")
 
+    # Send print start notifications
+    try:
+        async with async_session() as db:
+            from backend.app.models.printer import Printer
+            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}"
+            await notification_service.on_print_start(printer_id, printer_name, data, db)
+    except Exception as e:
+        import logging
+        logging.getLogger(__name__).warning(f"Notification on_print_start failed: {e}")
+
 
 async def on_print_complete(printer_id: int, data: dict):
     """Handle print completion - update the archive status."""
@@ -668,6 +683,25 @@ async def on_print_complete(printer_id: int, data: dict):
         import logging
         logging.getLogger(__name__).warning(f"Smart plug on_print_complete failed: {e}")
 
+    # Send print complete notifications
+    try:
+        async with async_session() as db:
+            from backend.app.models.printer import Printer
+            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}"
+            status = data.get("status", "completed")
+
+            # on_print_complete handles all status types: completed, failed, aborted, stopped
+            await notification_service.on_print_complete(
+                printer_id, printer_name, status, data, db
+            )
+    except Exception as e:
+        import logging
+        logging.getLogger(__name__).warning(f"Notification on_print_complete failed: {e}")
+
     # Update queue item if this was a scheduled print
     try:
         async with async_session() as db:
@@ -762,6 +796,7 @@ app.include_router(cloud.router, prefix=app_settings.api_prefix)
 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(websocket.router, prefix=app_settings.api_prefix)
 
 

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

@@ -0,0 +1,54 @@
+"""Notification provider model for push notifications."""
+
+from datetime import datetime
+
+from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, Time
+from sqlalchemy.orm import relationship
+
+from backend.app.core.database import Base
+
+
+class NotificationProvider(Base):
+    """Model for notification providers (WhatsApp, ntfy, Pushover, etc.)."""
+
+    __tablename__ = "notification_providers"
+
+    id = Column(Integer, primary_key=True, index=True)
+    name = Column(String(100), nullable=False)  # User-defined name
+    provider_type = Column(String(50), nullable=False)  # callmebot, ntfy, pushover, telegram, email
+    enabled = Column(Boolean, default=True)
+
+    # Provider-specific configuration stored as JSON string
+    config = Column(Text, nullable=False)
+
+    # Event triggers - print lifecycle
+    on_print_start = Column(Boolean, default=False)
+    on_print_complete = Column(Boolean, default=True)
+    on_print_failed = Column(Boolean, default=True)
+    on_print_stopped = Column(Boolean, default=True)  # User cancelled/stopped print
+    on_print_progress = Column(Boolean, default=False)  # 25%, 50%, 75% milestones
+
+    # Event triggers - printer status
+    on_printer_offline = Column(Boolean, default=False)
+    on_printer_error = Column(Boolean, default=False)  # AMS issues, etc.
+    on_filament_low = Column(Boolean, default=False)
+
+    # Quiet hours (do not disturb)
+    quiet_hours_enabled = Column(Boolean, default=False)
+    quiet_hours_start = Column(String(5), nullable=True)  # HH:MM format, e.g., "22:00"
+    quiet_hours_end = Column(String(5), nullable=True)  # HH:MM format, e.g., "07:00"
+
+    # Optional: Link to specific printer (NULL = all printers)
+    printer_id = Column(Integer, ForeignKey("printers.id", ondelete="SET NULL"), nullable=True)
+
+    # Status tracking
+    last_success = Column(DateTime, nullable=True)
+    last_error = Column(Text, nullable=True)
+    last_error_at = Column(DateTime, nullable=True)
+
+    # Timestamps
+    created_at = Column(DateTime, default=datetime.utcnow)
+    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+
+    # Relationships
+    printer = relationship("Printer", back_populates="notification_providers")

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

@@ -31,7 +31,11 @@ class Printer(Base):
     smart_plug: Mapped["SmartPlug | None"] = relationship(
         back_populates="printer", uselist=False
     )
+    notification_providers: Mapped[list["NotificationProvider"]] = relationship(
+        back_populates="printer"
+    )
 
 
 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

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

@@ -0,0 +1,168 @@
+"""Pydantic schemas for notification providers."""
+
+from datetime import datetime
+from enum import Enum
+from typing import Any
+
+from pydantic import BaseModel, Field, field_validator
+
+
+class ProviderType(str, Enum):
+    """Supported notification provider types."""
+
+    CALLMEBOT = "callmebot"
+    NTFY = "ntfy"
+    PUSHOVER = "pushover"
+    TELEGRAM = "telegram"
+    EMAIL = "email"
+
+
+class NotificationProviderBase(BaseModel):
+    """Base schema for notification providers."""
+
+    name: str = Field(..., min_length=1, max_length=100, description="User-defined name")
+    provider_type: ProviderType = Field(..., description="Type of notification provider")
+    enabled: bool = Field(default=True, description="Whether notifications are enabled")
+    config: dict[str, Any] = Field(..., description="Provider-specific configuration")
+
+    # Event triggers - print lifecycle
+    on_print_start: bool = Field(default=False, description="Notify on print start")
+    on_print_complete: bool = Field(default=True, description="Notify on print complete")
+    on_print_failed: bool = Field(default=True, description="Notify on print failed")
+    on_print_stopped: bool = Field(default=True, description="Notify when print is stopped/cancelled")
+    on_print_progress: bool = Field(default=False, description="Notify at 25%, 50%, 75% progress")
+
+    # Event triggers - printer status
+    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")
+
+    # Quiet hours
+    quiet_hours_enabled: bool = Field(default=False, description="Enable quiet hours")
+    quiet_hours_start: str | None = Field(default=None, description="Start time in HH:MM format")
+    quiet_hours_end: str | None = Field(default=None, description="End time in HH:MM format")
+
+    # Printer filter
+    printer_id: int | None = Field(default=None, description="Specific printer ID or null for all")
+
+    @field_validator("quiet_hours_start", "quiet_hours_end")
+    @classmethod
+    def validate_time_format(cls, v: str | None) -> str | None:
+        if v is None:
+            return v
+        try:
+            parts = v.split(":")
+            if len(parts) != 2:
+                raise ValueError("Invalid time format")
+            hour, minute = int(parts[0]), int(parts[1])
+            if not (0 <= hour <= 23 and 0 <= minute <= 59):
+                raise ValueError("Invalid time range")
+            return f"{hour:02d}:{minute:02d}"
+        except (ValueError, TypeError):
+            raise ValueError("Time must be in HH:MM format (e.g., 22:00)")
+
+
+class NotificationProviderCreate(NotificationProviderBase):
+    """Schema for creating a notification provider."""
+
+    pass
+
+
+class NotificationProviderUpdate(BaseModel):
+    """Schema for updating a notification provider (all fields optional)."""
+
+    name: str | None = Field(default=None, min_length=1, max_length=100)
+    provider_type: ProviderType | None = None
+    enabled: bool | None = None
+    config: dict[str, Any] | None = None
+
+    # Event triggers - print lifecycle
+    on_print_start: bool | None = None
+    on_print_complete: bool | None = None
+    on_print_failed: bool | None = None
+    on_print_stopped: bool | None = None
+    on_print_progress: bool | None = None
+
+    # Event triggers - printer status
+    on_printer_offline: bool | None = None
+    on_printer_error: bool | None = None
+    on_filament_low: bool | None = None
+
+    # Quiet hours
+    quiet_hours_enabled: bool | None = None
+    quiet_hours_start: str | None = None
+    quiet_hours_end: str | None = None
+
+    # Printer filter
+    printer_id: int | None = None
+
+
+class NotificationProviderResponse(NotificationProviderBase):
+    """Schema for notification provider API responses."""
+
+    id: int
+    last_success: datetime | None = None
+    last_error: str | None = None
+    last_error_at: datetime | None = None
+    created_at: datetime
+    updated_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
+class NotificationTestRequest(BaseModel):
+    """Schema for testing notification configuration."""
+
+    provider_type: ProviderType
+    config: dict[str, Any]
+
+
+class NotificationTestResponse(BaseModel):
+    """Schema for test notification response."""
+
+    success: bool
+    message: str
+
+
+# Provider-specific config schemas for documentation/validation reference
+class CallMeBotConfig(BaseModel):
+    """CallMeBot/WhatsApp configuration."""
+
+    phone: str = Field(..., description="Phone number with country code (e.g., +1234567890)")
+    apikey: str = Field(..., description="API key from CallMeBot")
+
+
+class NtfyConfig(BaseModel):
+    """ntfy configuration."""
+
+    server: str = Field(default="https://ntfy.sh", description="ntfy server URL")
+    topic: str = Field(..., description="Topic name to publish to")
+    auth_token: str | None = Field(default=None, description="Optional authentication token")
+
+
+class PushoverConfig(BaseModel):
+    """Pushover configuration."""
+
+    user_key: str = Field(..., description="Your Pushover user key")
+    app_token: str = Field(..., description="Your Pushover application token")
+    priority: int = Field(default=0, ge=-2, le=2, description="Message priority (-2 to 2)")
+
+
+class TelegramConfig(BaseModel):
+    """Telegram bot configuration."""
+
+    bot_token: str = Field(..., description="Bot token from @BotFather")
+    chat_id: str = Field(..., description="Chat ID to send messages to")
+
+
+class EmailConfig(BaseModel):
+    """Email/SMTP configuration."""
+
+    smtp_server: str = Field(..., description="SMTP server hostname")
+    smtp_port: int = Field(default=587, description="SMTP port (587 for TLS, 465 for SSL)")
+    username: str = Field(..., description="SMTP username/email")
+    password: str = Field(..., description="SMTP password or app password")
+    from_email: str = Field(..., description="From email address")
+    to_email: str = Field(..., description="Recipient email address")
+    use_tls: bool = Field(default=True, description="Use TLS encryption")

+ 534 - 0
backend/app/services/notification_service.py

@@ -0,0 +1,534 @@
+"""Notification service for sending push notifications via various providers."""
+
+import json
+import logging
+import smtplib
+from datetime import datetime
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+from typing import Any
+from urllib.parse import quote
+
+import httpx
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.notification import NotificationProvider
+
+logger = logging.getLogger(__name__)
+
+
+class NotificationService:
+    """Service for sending notifications through various providers."""
+
+    def __init__(self):
+        self._http_client: httpx.AsyncClient | None = None
+
+    async def _get_client(self) -> httpx.AsyncClient:
+        """Get or create HTTP client."""
+        if self._http_client is None or self._http_client.is_closed:
+            self._http_client = httpx.AsyncClient(timeout=30.0)
+        return self._http_client
+
+    async def close(self):
+        """Close HTTP client."""
+        if self._http_client and not self._http_client.is_closed:
+            await self._http_client.aclose()
+
+    def _is_in_quiet_hours(self, provider: NotificationProvider) -> bool:
+        """Check if current time is within provider's quiet hours."""
+        if not provider.quiet_hours_enabled:
+            return False
+
+        if not provider.quiet_hours_start or not provider.quiet_hours_end:
+            return False
+
+        try:
+            now = datetime.now()
+            current_time = now.hour * 60 + now.minute
+
+            start_parts = provider.quiet_hours_start.split(":")
+            end_parts = provider.quiet_hours_end.split(":")
+
+            start_minutes = int(start_parts[0]) * 60 + int(start_parts[1])
+            end_minutes = int(end_parts[0]) * 60 + int(end_parts[1])
+
+            # Handle overnight quiet hours (e.g., 22:00 to 07:00)
+            if start_minutes > end_minutes:
+                # Quiet hours span midnight
+                return current_time >= start_minutes or current_time < end_minutes
+            else:
+                # Same day quiet hours
+                return start_minutes <= current_time < end_minutes
+        except (ValueError, TypeError, AttributeError):
+            logger.warning(f"Invalid quiet hours format for provider {provider.name}")
+            return False
+
+    def _format_duration(self, seconds: int | None) -> str:
+        """Format duration in seconds to human-readable string."""
+        if seconds is None:
+            return "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]:
+        """Build notification message for print start event."""
+        filename = data.get("filename", "Unknown")
+        # Clean up filename
+        if filename.endswith(".gcode.3mf"):
+            filename = filename[:-10]
+        elif filename.endswith(".3mf"):
+            filename = filename[:-4]
+
+        title = "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)
+
+        message = f"{printer_name}: {filename}\nEstimated: {time_str}"
+        return title, message
+
+    def _build_print_complete_message(
+        self, printer_name: str, status: str, data: dict, archive_data: dict | None = None
+    ) -> tuple[str, str]:
+        """Build notification message for print complete event."""
+        filename = data.get("filename", "Unknown")
+        if filename.endswith(".gcode.3mf"):
+            filename = filename[:-10]
+        elif filename.endswith(".3mf"):
+            filename = filename[:-4]
+
+        if status == "completed":
+            title = "Print Completed"
+        elif status == "failed":
+            title = "Print Failed"
+        elif status in ("aborted", "stopped", "cancelled"):
+            title = "Print Stopped"
+        else:
+            title = "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'])}")
+            # Add filament used if available
+            if archive_data.get("actual_filament_grams"):
+                lines.append(f"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']}")
+
+        message = "\n".join(lines)
+        return title, message
+
+    def _build_progress_message(
+        self, printer_name: str, filename: str, progress: int
+    ) -> tuple[str, str]:
+        """Build notification message for print progress milestone."""
+        if filename.endswith(".gcode.3mf"):
+            filename = filename[:-10]
+        elif filename.endswith(".3mf"):
+            filename = filename[:-4]
+
+        title = f"Print {progress}% Complete"
+        message = f"{printer_name}: {filename}"
+        return title, message
+
+    def _build_printer_offline_message(self, printer_name: str) -> tuple[str, str]:
+        """Build notification message for printer offline event."""
+        title = "Printer Offline"
+        message = f"{printer_name} has disconnected"
+        return title, message
+
+    def _build_printer_error_message(
+        self, printer_name: str, error_type: str, error_detail: str | None = None
+    ) -> tuple[str, str]:
+        """Build notification message for printer error event."""
+        title = f"Printer Error: {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
+    ) -> tuple[str, str]:
+        """Build notification message for low filament event."""
+        title = "Filament Low"
+        message = f"{printer_name}: Slot {slot} at {remaining_percent}%"
+        return title, message
+
+    async def send_test_notification(
+        self, provider_type: str, config: dict[str, Any]
+    ) -> 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!"
+
+        try:
+            if provider_type == "callmebot":
+                return await self._send_callmebot(config, f"{title}\n{message}")
+            elif provider_type == "ntfy":
+                return await self._send_ntfy(config, title, message)
+            elif provider_type == "pushover":
+                return await self._send_pushover(config, title, message)
+            elif provider_type == "telegram":
+                return await self._send_telegram(config, f"*{title}*\n{message}")
+            elif provider_type == "email":
+                return await self._send_email(config, title, message)
+            else:
+                return False, f"Unknown provider type: {provider_type}"
+        except Exception as e:
+            logger.exception(f"Error sending test notification via {provider_type}")
+            return False, str(e)
+
+    async def _send_callmebot(self, config: dict, message: str) -> tuple[bool, str]:
+        """Send notification via CallMeBot (WhatsApp)."""
+        phone = config.get("phone", "").strip()
+        apikey = config.get("apikey", "").strip()
+
+        if not phone or not apikey:
+            return False, "Phone number and API key are required"
+
+        # URL encode the message
+        encoded_message = quote(message)
+        url = f"https://api.callmebot.com/whatsapp.php?phone={phone}&text={encoded_message}&apikey={apikey}"
+
+        client = await self._get_client()
+        response = await client.get(url)
+
+        if response.status_code == 200:
+            return True, "Message sent successfully"
+        else:
+            return False, f"HTTP {response.status_code}: {response.text[:200]}"
+
+    async def _send_ntfy(self, config: dict, title: str, message: str) -> tuple[bool, str]:
+        """Send notification via ntfy."""
+        server = config.get("server", "https://ntfy.sh").rstrip("/")
+        topic = config.get("topic", "").strip()
+        auth_token = config.get("auth_token", "").strip()
+
+        if not topic:
+            return False, "Topic is required"
+
+        url = f"{server}/{topic}"
+        headers = {"Title": title}
+
+        if auth_token:
+            headers["Authorization"] = f"Bearer {auth_token}"
+
+        client = await self._get_client()
+        response = await client.post(url, content=message, headers=headers)
+
+        if response.status_code in (200, 204):
+            return True, "Message sent successfully"
+        else:
+            return False, f"HTTP {response.status_code}: {response.text[:200]}"
+
+    async def _send_pushover(self, config: dict, title: str, message: str) -> tuple[bool, str]:
+        """Send notification via Pushover."""
+        user_key = config.get("user_key", "").strip()
+        app_token = config.get("app_token", "").strip()
+        priority = config.get("priority", 0)
+
+        if not user_key or not app_token:
+            return False, "User key and app token are required"
+
+        url = "https://api.pushover.net/1/messages.json"
+        data = {
+            "token": app_token,
+            "user": user_key,
+            "title": title,
+            "message": message,
+            "priority": priority,
+        }
+
+        client = await self._get_client()
+        response = await client.post(url, data=data)
+
+        if response.status_code == 200:
+            return True, "Message sent successfully"
+        else:
+            try:
+                error_data = response.json()
+                errors = error_data.get("errors", [])
+                return False, f"Pushover error: {', '.join(errors)}"
+            except Exception:
+                return False, f"HTTP {response.status_code}: {response.text[:200]}"
+
+    async def _send_telegram(self, config: dict, message: str) -> tuple[bool, str]:
+        """Send notification via Telegram bot."""
+        bot_token = config.get("bot_token", "").strip()
+        chat_id = config.get("chat_id", "").strip()
+
+        if not bot_token or not chat_id:
+            return False, "Bot token and chat ID are required"
+
+        url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
+        data = {
+            "chat_id": chat_id,
+            "text": message,
+            "parse_mode": "Markdown",
+        }
+
+        client = await self._get_client()
+        response = await client.post(url, json=data)
+
+        if response.status_code == 200:
+            result = response.json()
+            if result.get("ok"):
+                return True, "Message sent successfully"
+            else:
+                return False, f"Telegram error: {result.get('description', 'Unknown error')}"
+        else:
+            return False, f"HTTP {response.status_code}: {response.text[:200]}"
+
+    async def _send_email(self, config: dict, subject: str, body: str) -> tuple[bool, str]:
+        """Send notification via email (SMTP)."""
+        smtp_server = config.get("smtp_server", "").strip()
+        smtp_port = int(config.get("smtp_port", 587))
+        username = config.get("username", "").strip()
+        password = config.get("password", "").strip()
+        from_email = config.get("from_email", "").strip()
+        to_email = config.get("to_email", "").strip()
+        # Security: "starttls" (port 587), "ssl" (port 465), "none" (port 25)
+        security = config.get("security", "starttls")
+        # Authentication: "true" or "false"
+        auth_enabled = config.get("auth_enabled", "true").lower() == "true"
+
+        if not all([smtp_server, from_email, to_email]):
+            return False, "SMTP server, from email, and to email are required"
+
+        if auth_enabled and not all([username, password]):
+            return False, "Username and password are required when authentication is enabled"
+
+        try:
+            msg = MIMEMultipart()
+            msg["From"] = from_email
+            msg["To"] = to_email
+            msg["Subject"] = f"[BambuTrack] {subject}"
+            msg.attach(MIMEText(body, "plain"))
+
+            if security == "ssl":
+                # Direct SSL connection (typically port 465)
+                server = smtplib.SMTP_SSL(smtp_server, smtp_port)
+            elif security == "starttls":
+                # STARTTLS upgrade (typically port 587)
+                server = smtplib.SMTP(smtp_server, smtp_port)
+                server.starttls()
+            else:
+                # No encryption (typically port 25) - use with caution
+                server = smtplib.SMTP(smtp_server, smtp_port)
+
+            if auth_enabled:
+                server.login(username, password)
+
+            server.sendmail(from_email, to_email, msg.as_string())
+            server.quit()
+
+            return True, "Email sent successfully"
+        except smtplib.SMTPAuthenticationError:
+            return False, "SMTP authentication failed - check username/password"
+        except smtplib.SMTPException as e:
+            return False, f"SMTP error: {str(e)}"
+        except Exception as e:
+            return False, f"Email error: {str(e)}"
+
+    async def _send_to_provider(
+        self, provider: NotificationProvider, title: str, message: str
+    ) -> tuple[bool, str]:
+        """Send notification to a specific provider."""
+        # Check quiet hours
+        if self._is_in_quiet_hours(provider):
+            logger.info(f"Skipping notification to {provider.name} - quiet hours active")
+            return True, "Skipped - quiet hours"
+
+        config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
+
+        try:
+            if provider.provider_type == "callmebot":
+                return await self._send_callmebot(config, f"{title}\n{message}")
+            elif provider.provider_type == "ntfy":
+                return await self._send_ntfy(config, title, message)
+            elif provider.provider_type == "pushover":
+                return await self._send_pushover(config, title, message)
+            elif provider.provider_type == "telegram":
+                return await self._send_telegram(config, f"*{title}*\n{message}")
+            elif provider.provider_type == "email":
+                return await self._send_email(config, title, message)
+            else:
+                return False, f"Unknown provider type: {provider.provider_type}"
+        except Exception as e:
+            logger.exception(f"Error sending notification via {provider.provider_type}")
+            return False, str(e)
+
+    async def _update_provider_status(
+        self, db: AsyncSession, provider_id: int, success: bool, error: str | None = None
+    ):
+        """Update provider status after sending notification."""
+        result = await db.execute(
+            select(NotificationProvider).where(NotificationProvider.id == provider_id)
+        )
+        provider = result.scalar_one_or_none()
+        if provider:
+            if success:
+                provider.last_success = datetime.utcnow()
+            else:
+                provider.last_error = error
+                provider.last_error_at = datetime.utcnow()
+            await db.commit()
+
+    async def _get_providers_for_event(
+        self,
+        db: AsyncSession,
+        event_field: str,
+        printer_id: int | None = None,
+    ) -> list[NotificationProvider]:
+        """Get all enabled providers that want a specific event type."""
+        # Build the query dynamically based on event field
+        query = select(NotificationProvider).where(
+            NotificationProvider.enabled == True,
+            getattr(NotificationProvider, event_field) == True,
+        )
+
+        if printer_id is not None:
+            query = query.where(
+                (NotificationProvider.printer_id == None) | (NotificationProvider.printer_id == printer_id)
+            )
+
+        result = await db.execute(query)
+        return list(result.scalars().all())
+
+    async def _send_to_providers(
+        self,
+        providers: list[NotificationProvider],
+        title: str,
+        message: str,
+        db: AsyncSession,
+    ):
+        """Send notification to multiple providers."""
+        for provider in providers:
+            try:
+                success, error = await self._send_to_provider(provider, title, message)
+                await self._update_provider_status(db, provider.id, success, error if not success else None)
+                if success:
+                    logger.info(f"Sent notification via {provider.name}")
+                else:
+                    logger.warning(f"Failed to send notification via {provider.name}: {error}")
+            except Exception as e:
+                logger.exception(f"Error sending notification via {provider.name}")
+                await self._update_provider_status(db, provider.id, False, str(e))
+
+    async def on_print_start(
+        self, printer_id: int, printer_name: str, data: dict, db: AsyncSession
+    ):
+        """Handle print start event - send notifications to relevant providers."""
+        logger.info(f"on_print_start called for printer {printer_id} ({printer_name})")
+        providers = await self._get_providers_for_event(db, "on_print_start", printer_id)
+        if not providers:
+            logger.info(f"No notification providers configured for print_start event on printer {printer_id}")
+            return
+
+        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)
+        await self._send_to_providers(providers, title, message, db)
+
+    async def on_print_complete(
+        self,
+        printer_id: int,
+        printer_name: str,
+        status: str,
+        data: dict,
+        db: AsyncSession,
+        archive_data: dict | None = None,
+    ):
+        """Handle print complete event - send notifications to relevant providers."""
+        logger.info(f"on_print_complete called for printer {printer_id} ({printer_name}), status={status}")
+        # Determine which event type this is
+        if status == "completed":
+            event_field = "on_print_complete"
+        elif status in ("failed",):
+            event_field = "on_print_failed"
+        elif status in ("aborted", "stopped", "cancelled"):
+            event_field = "on_print_stopped"
+        else:
+            # Unknown status, default to on_print_complete
+            logger.warning(f"Unknown print status '{status}', defaulting to on_print_complete")
+            event_field = "on_print_complete"
+
+        providers = await self._get_providers_for_event(db, event_field, printer_id)
+        if not providers:
+            logger.info(f"No notification providers configured for {event_field} event on printer {printer_id}")
+            return
+
+        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)
+        await self._send_to_providers(providers, title, message, db)
+
+    async def on_print_progress(
+        self,
+        printer_id: int,
+        printer_name: str,
+        filename: str,
+        progress: int,
+        db: AsyncSession,
+    ):
+        """Handle print progress milestone (25%, 50%, 75%)."""
+        providers = await self._get_providers_for_event(db, "on_print_progress", printer_id)
+        if not providers:
+            return
+
+        title, message = self._build_progress_message(printer_name, filename, progress)
+        await self._send_to_providers(providers, title, message, db)
+
+    async def on_printer_offline(
+        self, printer_id: int, printer_name: str, db: AsyncSession
+    ):
+        """Handle printer offline event."""
+        providers = await self._get_providers_for_event(db, "on_printer_offline", printer_id)
+        if not providers:
+            return
+
+        title, message = self._build_printer_offline_message(printer_name)
+        await self._send_to_providers(providers, title, message, db)
+
+    async def on_printer_error(
+        self,
+        printer_id: int,
+        printer_name: str,
+        error_type: str,
+        db: AsyncSession,
+        error_detail: str | None = None,
+    ):
+        """Handle printer error event (AMS issues, etc.)."""
+        providers = await self._get_providers_for_event(db, "on_printer_error", printer_id)
+        if not providers:
+            return
+
+        title, message = self._build_printer_error_message(printer_name, error_type, error_detail)
+        await self._send_to_providers(providers, title, message, db)
+
+    async def on_filament_low(
+        self,
+        printer_id: int,
+        printer_name: str,
+        slot: int,
+        remaining_percent: int,
+        db: AsyncSession,
+    ):
+        """Handle low filament event."""
+        providers = await self._get_providers_for_event(db, "on_filament_low", printer_id)
+        if not providers:
+            return
+
+        title, message = self._build_filament_low_message(printer_name, slot, remaining_percent)
+        await self._send_to_providers(providers, title, message, db)
+
+
+# Global instance
+notification_service = NotificationService()

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

@@ -355,6 +355,129 @@ export interface KProfilesResponse {
   nozzle_diameter: string;
 }
 
+// Notification Provider types
+export type ProviderType = 'callmebot' | 'ntfy' | 'pushover' | 'telegram' | 'email';
+
+export interface NotificationProvider {
+  id: number;
+  name: string;
+  provider_type: ProviderType;
+  enabled: boolean;
+  config: Record<string, unknown>;
+  // Print lifecycle events
+  on_print_start: boolean;
+  on_print_complete: boolean;
+  on_print_failed: boolean;
+  on_print_stopped: boolean;
+  on_print_progress: boolean;
+  // Printer status events
+  on_printer_offline: boolean;
+  on_printer_error: boolean;
+  on_filament_low: boolean;
+  // Quiet hours
+  quiet_hours_enabled: boolean;
+  quiet_hours_start: string | null;
+  quiet_hours_end: string | null;
+  // Printer filter
+  printer_id: number | null;
+  // Status tracking
+  last_success: string | null;
+  last_error: string | null;
+  last_error_at: string | null;
+  // Timestamps
+  created_at: string;
+  updated_at: string;
+}
+
+export interface NotificationProviderCreate {
+  name: string;
+  provider_type: ProviderType;
+  enabled?: boolean;
+  config: Record<string, unknown>;
+  // Print lifecycle events
+  on_print_start?: boolean;
+  on_print_complete?: boolean;
+  on_print_failed?: boolean;
+  on_print_stopped?: boolean;
+  on_print_progress?: boolean;
+  // Printer status events
+  on_printer_offline?: boolean;
+  on_printer_error?: boolean;
+  on_filament_low?: boolean;
+  // Quiet hours
+  quiet_hours_enabled?: boolean;
+  quiet_hours_start?: string | null;
+  quiet_hours_end?: string | null;
+  // Printer filter
+  printer_id?: number | null;
+}
+
+export interface NotificationProviderUpdate {
+  name?: string;
+  provider_type?: ProviderType;
+  enabled?: boolean;
+  config?: Record<string, unknown>;
+  // Print lifecycle events
+  on_print_start?: boolean;
+  on_print_complete?: boolean;
+  on_print_failed?: boolean;
+  on_print_stopped?: boolean;
+  on_print_progress?: boolean;
+  // Printer status events
+  on_printer_offline?: boolean;
+  on_printer_error?: boolean;
+  on_filament_low?: boolean;
+  // Quiet hours
+  quiet_hours_enabled?: boolean;
+  quiet_hours_start?: string | null;
+  quiet_hours_end?: string | null;
+  // Printer filter
+  printer_id?: number | null;
+}
+
+export interface NotificationTestRequest {
+  provider_type: ProviderType;
+  config: Record<string, unknown>;
+}
+
+export interface NotificationTestResponse {
+  success: boolean;
+  message: string;
+}
+
+// Provider-specific config types for reference
+export interface CallMeBotConfig {
+  phone: string;
+  apikey: string;
+}
+
+export interface NtfyConfig {
+  server?: string;
+  topic: string;
+  auth_token?: string | null;
+}
+
+export interface PushoverConfig {
+  user_key: string;
+  app_token: string;
+  priority?: number;
+}
+
+export interface TelegramConfig {
+  bot_token: string;
+  chat_id: string;
+}
+
+export interface EmailConfig {
+  smtp_server: string;
+  smtp_port?: number;
+  username: string;
+  password: string;
+  from_email: string;
+  to_email: string;
+  use_tls?: boolean;
+}
+
 // API functions
 export const api = {
   // Printers
@@ -700,4 +823,27 @@ export const api = {
       method: 'DELETE',
       body: JSON.stringify(profile),
     }),
+
+  // Notification Providers
+  getNotificationProviders: () => request<NotificationProvider[]>('/notifications/'),
+  getNotificationProvider: (id: number) => request<NotificationProvider>(`/notifications/${id}`),
+  createNotificationProvider: (data: NotificationProviderCreate) =>
+    request<NotificationProvider>('/notifications/', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  updateNotificationProvider: (id: number, data: NotificationProviderUpdate) =>
+    request<NotificationProvider>(`/notifications/${id}`, {
+      method: 'PATCH',
+      body: JSON.stringify(data),
+    }),
+  deleteNotificationProvider: (id: number) =>
+    request<{ message: string }>(`/notifications/${id}`, { method: 'DELETE' }),
+  testNotificationProvider: (id: number) =>
+    request<NotificationTestResponse>(`/notifications/${id}/test`, { method: 'POST' }),
+  testNotificationConfig: (data: NotificationTestRequest) =>
+    request<NotificationTestResponse>('/notifications/test-config', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
 };

+ 414 - 0
frontend/src/components/AddNotificationModal.tsx

@@ -0,0 +1,414 @@
+import { useState, useEffect } from 'react';
+import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
+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';
+
+interface AddNotificationModalProps {
+  provider?: NotificationProvider | null;
+  onClose: () => void;
+}
+
+const PROVIDER_OPTIONS: { value: ProviderType; label: string; description: string }[] = [
+  { value: 'callmebot', label: 'CallMeBot/WhatsApp', description: 'Free WhatsApp notifications via CallMeBot' },
+  { value: 'ntfy', label: 'ntfy', description: 'Free, self-hostable push notifications' },
+  { value: 'pushover', label: 'Pushover', description: 'Simple, reliable push notifications' },
+  { value: 'telegram', label: 'Telegram', description: 'Notifications via Telegram bot' },
+  { value: 'email', label: 'Email', description: 'SMTP email notifications' },
+];
+
+export function AddNotificationModal({ provider, onClose }: AddNotificationModalProps) {
+  const queryClient = useQueryClient();
+  const isEditing = !!provider;
+
+  const [name, setName] = useState(provider?.name || '');
+  const [providerType, setProviderType] = useState<ProviderType>(provider?.provider_type || 'ntfy');
+  const [printerId, setPrinterId] = useState<number | null>(provider?.printer_id || null);
+  const [quietHoursEnabled, setQuietHoursEnabled] = useState(provider?.quiet_hours_enabled || false);
+  const [quietHoursStart, setQuietHoursStart] = useState(provider?.quiet_hours_start || '22:00');
+  const [quietHoursEnd, setQuietHoursEnd] = useState(provider?.quiet_hours_end || '07:00');
+
+  // Provider-specific config
+  const [config, setConfig] = useState<Record<string, string>>(
+    provider?.config ? Object.fromEntries(Object.entries(provider.config).map(([k, v]) => [k, String(v)])) : {}
+  );
+
+  const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
+  const [error, setError] = useState<string | null>(null);
+
+  // Fetch printers for linking
+  const { data: printers } = useQuery({
+    queryKey: ['printers'],
+    queryFn: api.getPrinters,
+  });
+
+  // Close on Escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  // Test configuration mutation
+  const testMutation = useMutation({
+    mutationFn: () => api.testNotificationConfig({ provider_type: providerType, config }),
+    onSuccess: (result) => {
+      setTestResult(result);
+      setError(null);
+    },
+    onError: (err: Error) => {
+      setTestResult({ success: false, message: err.message });
+    },
+  });
+
+  // Create mutation
+  const createMutation = useMutation({
+    mutationFn: (data: NotificationProviderCreate) => api.createNotificationProvider(data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['notification-providers'] });
+      onClose();
+    },
+    onError: (err: Error) => {
+      setError(err.message);
+    },
+  });
+
+  // Update mutation
+  const updateMutation = useMutation({
+    mutationFn: (data: NotificationProviderUpdate) => api.updateNotificationProvider(provider!.id, data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['notification-providers'] });
+      onClose();
+    },
+    onError: (err: Error) => {
+      setError(err.message);
+    },
+  });
+
+  const handleSubmit = (e: React.FormEvent) => {
+    e.preventDefault();
+    setError(null);
+
+    if (!name.trim()) {
+      setError('Name is required');
+      return;
+    }
+
+    // Validate provider-specific config
+    const requiredFields = getRequiredFields(providerType);
+    for (const field of requiredFields) {
+      if (!config[field.key]?.trim()) {
+        setError(`${field.label} is required`);
+        return;
+      }
+    }
+
+    const data = {
+      name: name.trim(),
+      provider_type: providerType,
+      config,
+      printer_id: printerId,
+      quiet_hours_enabled: quietHoursEnabled,
+      quiet_hours_start: quietHoursEnabled ? quietHoursStart : null,
+      quiet_hours_end: quietHoursEnabled ? quietHoursEnd : null,
+    };
+
+    if (isEditing) {
+      updateMutation.mutate(data);
+    } else {
+      createMutation.mutate(data);
+    }
+  };
+
+  const isPending = createMutation.isPending || updateMutation.isPending;
+
+  // Get config fields for each provider type
+  const getConfigFields = (type: ProviderType) => {
+    switch (type) {
+      case 'callmebot':
+        return [
+          { key: 'phone', label: 'Phone Number', placeholder: '+1234567890', type: 'text', required: true },
+          { key: 'apikey', label: 'API Key', placeholder: 'Your CallMeBot API key', type: 'text', required: true },
+        ];
+      case 'ntfy':
+        return [
+          { key: 'server', label: 'Server URL', placeholder: 'https://ntfy.sh', type: 'text', required: false },
+          { key: 'topic', label: 'Topic', placeholder: 'my-bambutrack', type: 'text', required: true },
+          { key: 'auth_token', label: 'Auth Token', placeholder: 'Optional authentication', type: 'password', required: false },
+        ];
+      case 'pushover':
+        return [
+          { key: 'user_key', label: 'User Key', placeholder: 'Your Pushover user key', type: 'text', required: true },
+          { key: 'app_token', label: 'App Token', placeholder: 'Your Pushover app token', type: 'text', required: true },
+          { key: 'priority', label: 'Priority', placeholder: '0 (normal)', type: 'number', required: false },
+        ];
+      case 'telegram':
+        return [
+          { key: 'bot_token', label: 'Bot Token', placeholder: 'Bot token from @BotFather', type: 'password', required: true },
+          { key: 'chat_id', label: 'Chat ID', placeholder: 'Your chat or group ID', type: 'text', required: true },
+        ];
+      case 'email':
+        return [
+          { key: 'smtp_server', label: 'SMTP Server', placeholder: 'smtp.gmail.com', type: 'text', required: true },
+          { key: 'smtp_port', label: 'SMTP Port', placeholder: '587', type: 'number', required: false },
+          { key: 'security', label: 'Security', type: 'select', required: false, options: [
+            { value: 'starttls', label: 'STARTTLS (Port 587)' },
+            { value: 'ssl', label: 'SSL/TLS (Port 465)' },
+            { value: 'none', label: 'None (Port 25)' },
+          ]},
+          { key: 'auth_enabled', label: 'Authentication', type: 'select', required: false, options: [
+            { value: 'true', label: 'Enabled' },
+            { value: 'false', label: 'Disabled' },
+          ]},
+          { key: 'username', label: 'Username', placeholder: 'your@email.com', type: 'text', required: false },
+          { key: 'password', label: 'Password', placeholder: 'App password', type: 'password', required: false },
+          { key: 'from_email', label: 'From Email', placeholder: 'your@email.com', type: 'text', required: true },
+          { key: 'to_email', label: 'To Email', placeholder: 'recipient@email.com', type: 'text', required: true },
+        ];
+      default:
+        return [];
+    }
+  };
+
+  const getRequiredFields = (type: ProviderType) => {
+    return getConfigFields(type).filter(f => f.required);
+  };
+
+  const configFields = getConfigFields(providerType);
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4 overflow-y-auto"
+      onClick={onClose}
+    >
+      <div
+        className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg my-8"
+        onClick={(e) => e.stopPropagation()}
+      >
+        {/* Header */}
+        <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
+          <h2 className="text-lg font-semibold text-white">
+            {isEditing ? 'Edit Notification Provider' : 'Add Notification Provider'}
+          </h2>
+          <button
+            onClick={onClose}
+            className="text-bambu-gray hover:text-white transition-colors"
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        {/* Form */}
+        <form onSubmit={handleSubmit} className="p-6 space-y-4">
+          {error && (
+            <div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
+              {error}
+            </div>
+          )}
+
+          {/* Name */}
+          <div>
+            <label className="block text-sm text-bambu-gray mb-1">Name *</label>
+            <input
+              type="text"
+              value={name}
+              onChange={(e) => setName(e.target.value)}
+              placeholder="My Notifications"
+              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"
+            />
+          </div>
+
+          {/* Provider Type */}
+          <div>
+            <label className="block text-sm text-bambu-gray mb-1">Provider Type *</label>
+            <select
+              value={providerType}
+              onChange={(e) => {
+                setProviderType(e.target.value as ProviderType);
+                setConfig({}); // Reset config when changing type
+                setTestResult(null);
+              }}
+              disabled={isEditing}
+              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"
+            >
+              {PROVIDER_OPTIONS.map((option) => (
+                <option key={option.value} value={option.value}>
+                  {option.label}
+                </option>
+              ))}
+            </select>
+            <p className="text-xs text-bambu-gray mt-1">
+              {PROVIDER_OPTIONS.find(o => o.value === providerType)?.description}
+            </p>
+          </div>
+
+          {/* Provider-specific configuration */}
+          <div className="space-y-3">
+            <p className="text-sm text-bambu-gray">Configuration</p>
+            {configFields.map((field) => (
+              <div key={field.key}>
+                <label className="block text-sm text-bambu-gray mb-1">
+                  {field.label} {field.required && '*'}
+                </label>
+                {field.type === 'select' && field.options ? (
+                  <select
+                    value={config[field.key] || field.options[0]?.value || ''}
+                    onChange={(e) => {
+                      setConfig({ ...config, [field.key]: e.target.value });
+                      setTestResult(null);
+                    }}
+                    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"
+                  >
+                    {field.options.map((opt) => (
+                      <option key={opt.value} value={opt.value}>
+                        {opt.label}
+                      </option>
+                    ))}
+                  </select>
+                ) : (
+                  <input
+                    type={field.type}
+                    value={config[field.key] || ''}
+                    onChange={(e) => {
+                      setConfig({ ...config, [field.key]: e.target.value });
+                      setTestResult(null);
+                    }}
+                    placeholder={field.placeholder}
+                    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"
+                  />
+                )}
+              </div>
+            ))}
+          </div>
+
+          {/* Test Button */}
+          <div className="flex gap-2">
+            <Button
+              type="button"
+              variant="secondary"
+              onClick={() => {
+                setTestResult(null);
+                testMutation.mutate();
+              }}
+              disabled={testMutation.isPending || !config[getRequiredFields(providerType)[0]?.key]}
+              className="flex-1"
+            >
+              {testMutation.isPending ? (
+                <Loader2 className="w-4 h-4 animate-spin" />
+              ) : (
+                <Send className="w-4 h-4" />
+              )}
+              Test Configuration
+            </Button>
+          </div>
+
+          {/* Test Result */}
+          {testResult && (
+            <div className={`p-3 rounded-lg flex items-center gap-2 ${
+              testResult.success
+                ? 'bg-bambu-green/20 border border-bambu-green/50 text-bambu-green'
+                : 'bg-red-500/20 border border-red-500/50 text-red-400'
+            }`}>
+              {testResult.success ? (
+                <>
+                  <CheckCircle className="w-5 h-5" />
+                  <span>{testResult.message}</span>
+                </>
+              ) : (
+                <>
+                  <XCircle className="w-5 h-5" />
+                  <span>{testResult.message}</span>
+                </>
+              )}
+            </div>
+          )}
+
+          {/* Link to Printer */}
+          <div>
+            <label className="block text-sm text-bambu-gray mb-1">Printer Filter</label>
+            <select
+              value={printerId ?? ''}
+              onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
+              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"
+            >
+              <option value="">All printers</option>
+              {printers?.map((p) => (
+                <option key={p.id} value={p.id}>
+                  {p.name}
+                </option>
+              ))}
+            </select>
+            <p className="text-xs text-bambu-gray mt-1">
+              Only send notifications for events from this printer
+            </p>
+          </div>
+
+          {/* Quiet Hours */}
+          <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>
+            </div>
+            {quietHoursEnabled && (
+              <div className="grid grid-cols-2 gap-3">
+                <div>
+                  <label className="block text-xs text-bambu-gray mb-1">Start</label>
+                  <input
+                    type="time"
+                    value={quietHoursStart}
+                    onChange={(e) => setQuietHoursStart(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"
+                  />
+                </div>
+                <div>
+                  <label className="block text-xs text-bambu-gray mb-1">End</label>
+                  <input
+                    type="time"
+                    value={quietHoursEnd}
+                    onChange={(e) => setQuietHoursEnd(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"
+                  />
+                </div>
+              </div>
+            )}
+          </div>
+
+          {/* Actions */}
+          <div className="flex gap-3 pt-2">
+            <Button
+              type="button"
+              variant="secondary"
+              onClick={onClose}
+              className="flex-1"
+            >
+              Cancel
+            </Button>
+            <Button
+              type="submit"
+              disabled={isPending}
+              className="flex-1"
+            >
+              {isPending ? (
+                <Loader2 className="w-4 h-4 animate-spin" />
+              ) : (
+                <Save className="w-4 h-4" />
+              )}
+              {isEditing ? 'Save' : 'Add'}
+            </Button>
+          </div>
+        </form>
+      </div>
+    </div>
+  );
+}

+ 408 - 0
frontend/src/components/NotificationProviderCard.tsx

@@ -0,0 +1,408 @@
+import { useState } from 'react';
+import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
+import { Bell, Trash2, Settings2, Edit2, Send, Loader2, CheckCircle, XCircle, Moon, Clock, ChevronDown, ChevronUp } from 'lucide-react';
+import { api } from '../api/client';
+import type { NotificationProvider, NotificationProviderUpdate } from '../api/client';
+import { Card, CardContent } from './Card';
+import { Button } from './Button';
+import { ConfirmModal } from './ConfirmModal';
+
+interface NotificationProviderCardProps {
+  provider: NotificationProvider;
+  onEdit: (provider: NotificationProvider) => void;
+}
+
+const PROVIDER_LABELS: Record<string, string> = {
+  callmebot: 'CallMeBot/WhatsApp',
+  ntfy: 'ntfy',
+  pushover: 'Pushover',
+  telegram: 'Telegram',
+  email: 'Email',
+};
+
+export function NotificationProviderCard({ provider, onEdit }: NotificationProviderCardProps) {
+  const queryClient = useQueryClient();
+  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+  const [isExpanded, setIsExpanded] = useState(false);
+  const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
+
+  // Fetch printers for linking
+  const { data: printers } = useQuery({
+    queryKey: ['printers'],
+    queryFn: api.getPrinters,
+  });
+
+  const linkedPrinter = printers?.find(p => p.id === provider.printer_id);
+
+  // Update mutation
+  const updateMutation = useMutation({
+    mutationFn: (data: NotificationProviderUpdate) => api.updateNotificationProvider(provider.id, data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['notification-providers'] });
+    },
+  });
+
+  // Delete mutation
+  const deleteMutation = useMutation({
+    mutationFn: () => api.deleteNotificationProvider(provider.id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['notification-providers'] });
+    },
+  });
+
+  // Test mutation
+  const testMutation = useMutation({
+    mutationFn: () => api.testNotificationProvider(provider.id),
+    onSuccess: (result) => {
+      setTestResult(result);
+      queryClient.invalidateQueries({ queryKey: ['notification-providers'] });
+    },
+    onError: (err: Error) => {
+      setTestResult({ success: false, message: err.message });
+    },
+  });
+
+  // Format time for display
+  const formatTime = (time: string | null) => {
+    if (!time) return '';
+    return time;
+  };
+
+  return (
+    <>
+      <Card className="relative">
+        <CardContent className="p-4">
+          {/* Header Row */}
+          <div className="flex items-start justify-between mb-3">
+            <div className="flex items-center gap-3">
+              <div className={`p-2 rounded-lg ${provider.enabled ? 'bg-bambu-green/20' : 'bg-bambu-dark'}`}>
+                <Bell className={`w-5 h-5 ${provider.enabled ? 'text-bambu-green' : 'text-bambu-gray'}`} />
+              </div>
+              <div>
+                <h3 className="font-medium text-white">{provider.name}</h3>
+                <p className="text-sm text-bambu-gray">{PROVIDER_LABELS[provider.provider_type] || provider.provider_type}</p>
+              </div>
+            </div>
+
+            {/* Status indicator */}
+            <div className="flex items-center gap-2">
+              {provider.last_success && (
+                <span className="text-xs text-bambu-green">Last sent: {new Date(provider.last_success).toLocaleDateString()}</span>
+              )}
+              {provider.last_error && (
+                <span className="text-xs text-red-400" title={provider.last_error}>Error</span>
+              )}
+            </div>
+          </div>
+
+          {/* Linked Printer */}
+          {linkedPrinter && (
+            <div className="mb-3 px-2 py-1.5 bg-bambu-dark rounded-lg">
+              <span className="text-xs text-bambu-gray">Printer: </span>
+              <span className="text-sm text-white">{linkedPrinter.name}</span>
+            </div>
+          )}
+          {!linkedPrinter && !provider.printer_id && (
+            <div className="mb-3 px-2 py-1.5 bg-bambu-dark rounded-lg">
+              <span className="text-xs text-bambu-gray">All printers</span>
+            </div>
+          )}
+
+          {/* Event summary - show all event tags */}
+          <div className="mb-3 flex flex-wrap gap-1">
+            {provider.on_print_start && (
+              <span className="px-2 py-0.5 bg-blue-500/20 text-blue-400 text-xs rounded">Start</span>
+            )}
+            {provider.on_print_complete && (
+              <span className="px-2 py-0.5 bg-bambu-green/20 text-bambu-green text-xs rounded">Complete</span>
+            )}
+            {provider.on_print_failed && (
+              <span className="px-2 py-0.5 bg-red-500/20 text-red-400 text-xs rounded">Failed</span>
+            )}
+            {provider.on_print_stopped && (
+              <span className="px-2 py-0.5 bg-orange-500/20 text-orange-400 text-xs rounded">Stopped</span>
+            )}
+            {provider.on_print_progress && (
+              <span className="px-2 py-0.5 bg-yellow-500/20 text-yellow-400 text-xs rounded">Progress</span>
+            )}
+            {provider.on_printer_offline && (
+              <span className="px-2 py-0.5 bg-gray-500/20 text-gray-400 text-xs rounded">Offline</span>
+            )}
+            {provider.on_printer_error && (
+              <span className="px-2 py-0.5 bg-orange-500/20 text-orange-400 text-xs rounded">Error</span>
+            )}
+            {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.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" />
+                Quiet
+              </span>
+            )}
+          </div>
+
+          {/* Test Button */}
+          <div className="mb-3">
+            <Button
+              size="sm"
+              variant="secondary"
+              disabled={testMutation.isPending}
+              onClick={() => {
+                setTestResult(null);
+                testMutation.mutate();
+              }}
+              className="w-full"
+            >
+              {testMutation.isPending ? (
+                <Loader2 className="w-4 h-4 animate-spin" />
+              ) : (
+                <Send className="w-4 h-4" />
+              )}
+              Send Test Notification
+            </Button>
+          </div>
+
+          {/* Test Result */}
+          {testResult && (
+            <div className={`mb-3 p-2 rounded-lg flex items-center gap-2 text-sm ${
+              testResult.success
+                ? 'bg-bambu-green/20 text-bambu-green'
+                : 'bg-red-500/20 text-red-400'
+            }`}>
+              {testResult.success ? (
+                <CheckCircle className="w-4 h-4" />
+              ) : (
+                <XCircle className="w-4 h-4" />
+              )}
+              <span>{testResult.message}</span>
+            </div>
+          )}
+
+          {/* Toggle Settings Panel */}
+          <button
+            onClick={() => setIsExpanded(!isExpanded)}
+            className="w-full flex items-center justify-between py-2 text-sm text-bambu-gray hover:text-white transition-colors border-t border-bambu-dark-tertiary"
+          >
+            <span className="flex items-center gap-2">
+              <Settings2 className="w-4 h-4" />
+              Event Settings
+            </span>
+            {isExpanded ? (
+              <ChevronUp className="w-4 h-4" />
+            ) : (
+              <ChevronDown className="w-4 h-4" />
+            )}
+          </button>
+
+          {/* Expanded Settings */}
+          {isExpanded && (
+            <div className="pt-3 border-t border-bambu-dark-tertiary space-y-4">
+              {/* Enabled Toggle */}
+              <div className="flex items-center justify-between">
+                <div>
+                  <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>
+              </div>
+
+              {/* Print Lifecycle Events */}
+              <div className="space-y-2">
+                <p className="text-xs text-bambu-gray uppercase tracking-wide">Print Events</p>
+
+                <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>
+                </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>
+                </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>
+                </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>
+                </div>
+
+                <div className="flex items-center justify-between">
+                  <div>
+                    <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>
+                </div>
+              </div>
+
+              {/* Printer Status Events */}
+              <div className="space-y-2">
+                <p className="text-xs text-bambu-gray uppercase tracking-wide">Printer Status</p>
+
+                <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>
+                </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>
+                </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>
+                </div>
+              </div>
+
+              {/* Quiet Hours */}
+              <div className="space-y-2">
+                <div className="flex items-center justify-between">
+                  <div className="flex items-center gap-2">
+                    <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>
+                </div>
+
+                {provider.quiet_hours_enabled && (
+                  <div className="pl-4 border-l-2 border-bambu-dark-tertiary space-y-2">
+                    <p className="text-xs text-bambu-gray">No notifications during these hours</p>
+                    <div className="flex items-center gap-2">
+                      <Clock className="w-4 h-4 text-bambu-gray" />
+                      <span className="text-sm text-white">
+                        {formatTime(provider.quiet_hours_start) || '22:00'} - {formatTime(provider.quiet_hours_end) || '07:00'}
+                      </span>
+                    </div>
+                    <p className="text-xs text-bambu-gray">Edit provider to change quiet hours</p>
+                  </div>
+                )}
+              </div>
+
+              {/* Action Buttons */}
+              <div className="flex gap-2 pt-2">
+                <Button
+                  size="sm"
+                  variant="secondary"
+                  onClick={() => onEdit(provider)}
+                  className="flex-1"
+                >
+                  <Edit2 className="w-4 h-4" />
+                  Edit
+                </Button>
+                <Button
+                  size="sm"
+                  variant="secondary"
+                  onClick={() => setShowDeleteConfirm(true)}
+                  className="text-red-400 hover:text-red-300"
+                >
+                  <Trash2 className="w-4 h-4" />
+                </Button>
+              </div>
+            </div>
+          )}
+        </CardContent>
+      </Card>
+
+      {/* Delete Confirmation */}
+      {showDeleteConfirm && (
+        <ConfirmModal
+          title="Delete Notification Provider"
+          message={`Are you sure you want to delete "${provider.name}"? This cannot be undone.`}
+          confirmText="Delete"
+          variant="danger"
+          onConfirm={() => {
+            deleteMutation.mutate();
+            setShowDeleteConfirm(false);
+          }}
+          onCancel={() => setShowDeleteConfirm(false)}
+        />
+      )}
+    </>
+  );
+}

+ 76 - 3
frontend/src/pages/SettingsPage.tsx

@@ -1,11 +1,13 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Save, Loader2, Check, Plus, Plug, AlertTriangle, RotateCcw } from 'lucide-react';
+import { Save, Loader2, Check, Plus, Plug, AlertTriangle, RotateCcw, Bell } from 'lucide-react';
 import { api } from '../api/client';
-import type { AppSettings, SmartPlug } from '../api/client';
+import type { AppSettings, SmartPlug, NotificationProvider } 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 { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
 import { useState, useEffect } from 'react';
 
@@ -16,6 +18,8 @@ export function SettingsPage() {
   const [showSaved, setShowSaved] = useState(false);
   const [showPlugModal, setShowPlugModal] = useState(false);
   const [editingPlug, setEditingPlug] = useState<SmartPlug | null>(null);
+  const [showNotificationModal, setShowNotificationModal] = useState(false);
+  const [editingProvider, setEditingProvider] = useState<NotificationProvider | null>(null);
   const [defaultView, setDefaultViewState] = useState<string>(getDefaultView());
 
   const handleDefaultViewChange = (path: string) => {
@@ -38,6 +42,11 @@ export function SettingsPage() {
     queryFn: api.getSmartPlugs,
   });
 
+  const { data: notificationProviders, isLoading: providersLoading } = useQuery({
+    queryKey: ['notification-providers'],
+    queryFn: api.getNotificationProviders,
+  });
+
   const { data: ffmpegStatus } = useQuery({
     queryKey: ['ffmpeg-status'],
     queryFn: api.checkFfmpeg,
@@ -335,7 +344,7 @@ export function SettingsPage() {
           </Card>
         </div>
 
-        {/* Right Column - Smart Plugs */}
+        {/* Middle Column - Smart Plugs */}
         <div className="w-96 flex-shrink-0">
           <Card>
             <CardHeader>
@@ -387,6 +396,59 @@ export function SettingsPage() {
             </CardContent>
           </Card>
         </div>
+
+        {/* Right Column - Notifications */}
+        <div className="w-96 flex-shrink-0">
+          <Card>
+            <CardHeader>
+              <div className="flex items-center justify-between">
+                <div className="flex items-center gap-2">
+                  <Bell className="w-5 h-5 text-bambu-green" />
+                  <h2 className="text-lg font-semibold text-white">Notifications</h2>
+                </div>
+                <Button
+                  size="sm"
+                  onClick={() => {
+                    setEditingProvider(null);
+                    setShowNotificationModal(true);
+                  }}
+                >
+                  <Plus className="w-4 h-4" />
+                  Add
+                </Button>
+              </div>
+            </CardHeader>
+            <CardContent>
+              <p className="text-sm text-bambu-gray mb-4">
+                Get notified about print events via WhatsApp, Telegram, Email, and more.
+              </p>
+              {providersLoading ? (
+                <div className="flex justify-center py-8">
+                  <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
+                </div>
+              ) : notificationProviders && notificationProviders.length > 0 ? (
+                <div className="space-y-4">
+                  {notificationProviders.map((provider) => (
+                    <NotificationProviderCard
+                      key={provider.id}
+                      provider={provider}
+                      onEdit={(p) => {
+                        setEditingProvider(p);
+                        setShowNotificationModal(true);
+                      }}
+                    />
+                  ))}
+                </div>
+              ) : (
+                <div className="text-center py-8 text-bambu-gray">
+                  <Bell className="w-12 h-12 mx-auto mb-3 opacity-30" />
+                  <p>No notification providers configured</p>
+                  <p className="text-sm mt-1">Add a provider to get started</p>
+                </div>
+              )}
+            </CardContent>
+          </Card>
+        </div>
       </div>
 
       {/* Smart Plug Modal */}
@@ -399,6 +461,17 @@ export function SettingsPage() {
           }}
         />
       )}
+
+      {/* Notification Modal */}
+      {showNotificationModal && (
+        <AddNotificationModal
+          provider={editingProvider}
+          onClose={() => {
+            setShowNotificationModal(false);
+            setEditingProvider(null);
+          }}
+        />
+      )}
     </div>
   );
 }

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


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


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DJNRCg8M.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-C2qF9pCa.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-B9G6rzsh.css">
+    <script type="module" crossorigin src="/assets/index-0UQnyYo8.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DJNRCg8M.css">
   </head>
   <body>
     <div id="root"></div>

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