Explorar el Código

Merge pull request #16 from maziggy/0.1.5b

- Fixed os.path issue in update module
- Updated screenshots
- Added user options to backup module
- Fixed bug, where switched off printers still show as active on printer page card
- Improved backup/restore module
MartinNYHC hace 5 meses
padre
commit
c021182429
Se han modificado 48 ficheros con 1805 adiciones y 281 borrados
  1. 1 0
      .gitignore
  2. 2 2
      PLAN.md
  3. 53 53
      README.md
  4. 711 66
      backend/app/api/routes/settings.py
  5. 110 20
      backend/app/api/routes/updates.py
  6. 32 4
      backend/app/core/config.py
  7. 4 4
      backend/app/i18n/__init__.py
  8. 3 3
      backend/app/main.py
  9. 1 1
      backend/app/models/notification_template.py
  10. 10 10
      backend/app/schemas/notification_template.py
  11. 1 1
      backend/app/services/bambu_cloud.py
  12. 22 1
      backend/app/services/bambu_mqtt.py
  13. 4 4
      backend/app/services/notification_service.py
  14. 16 9
      backend/app/services/printer_manager.py
  15. 1 1
      frontend/index.html
  16. BIN
      frontend/public/img/android-chrome-192x192.png
  17. BIN
      frontend/public/img/android-chrome-512x512.png
  18. BIN
      frontend/public/img/apple-touch-icon.png
  19. BIN
      frontend/public/img/bambuddy_logo_dark.png
  20. BIN
      frontend/public/img/bambuddy_logo_light.png
  21. BIN
      frontend/public/img/bambusy_logo_dark.png
  22. BIN
      frontend/public/img/bambusy_logo_light.png
  23. BIN
      frontend/public/img/favicon-16x16.png
  24. BIN
      frontend/public/img/favicon-32x32.png
  25. BIN
      frontend/public/img/favicon.png
  26. 34 6
      frontend/src/api/client.ts
  27. 1 1
      frontend/src/components/AddNotificationModal.tsx
  28. 295 0
      frontend/src/components/BackupModal.tsx
  29. 1 1
      frontend/src/components/KProfilesView.tsx
  30. 47 5
      frontend/src/components/Layout.tsx
  31. 370 0
      frontend/src/components/RestoreModal.tsx
  32. 21 24
      frontend/src/components/SpoolmanSettings.tsx
  33. 1 1
      frontend/src/pages/CameraPage.tsx
  34. 61 61
      frontend/src/pages/SettingsPage.tsx
  35. 0 0
      static/assets/index-Crbfjp9b.css
  36. 0 0
      static/assets/index-CycmYzoY.js
  37. 0 0
      static/assets/index-Ob3MFXab.css
  38. BIN
      static/img/android-chrome-192x192.png
  39. BIN
      static/img/android-chrome-512x512.png
  40. BIN
      static/img/apple-touch-icon.png
  41. BIN
      static/img/bambuddy_logo_dark.png
  42. BIN
      static/img/bambuddy_logo_light.png
  43. BIN
      static/img/bambusy_logo_dark.png
  44. BIN
      static/img/bambusy_logo_light.png
  45. BIN
      static/img/favicon-16x16.png
  46. BIN
      static/img/favicon-32x32.png
  47. BIN
      static/img/favicon.png
  48. 3 3
      static/index.html

+ 1 - 0
.gitignore

@@ -43,3 +43,4 @@ archive/
 *.log
 logs/
 *.log*
+bambutrack.log.*

+ 2 - 2
PLAN.md

@@ -58,7 +58,7 @@ Variables use `{variable_name}` syntax (Python format strings).
 
 ### Common Variables (all events):
 - `{timestamp}` - Current date/time
-- `{app_name}` - "BambuTrack"
+- `{app_name}` - "Bambuddy"
 
 ---
 
@@ -104,7 +104,7 @@ maintenance_due:
   body: "{printer}:\n{items}"
 
 test:
-  title: "BambuTrack Test"
+  title: "Bambuddy Test"
   body: "This is a test notification. If you see this, notifications are working!"
 ```
 

+ 53 - 53
README.md

@@ -2,7 +2,7 @@ TESTERS NEEDED!!!
 Since I only have X1C and H2D devices, I'm not able to test the application with other Bambu Lab models. Collaborate today and help the project to support the whole Bambu Lab printer family!
 
 <p align="center">
-  <img src="static/img/bambusy_logo_dark.png" alt="Bambusy Logo" width="300">
+  <img src="static/img/bambuddy_logo_dark.png" alt="Bambuddy Logo" width="300">
 </p>
 
 <p align="center">
@@ -188,7 +188,7 @@ Since I only have X1C and H2D devices, I'm not able to test the application with
 
 ### Network Requirements
 - Bambu Lab printer with **LAN Mode** enabled
-- Printer and Bambusy server must be on the same local network
+- Printer and Bambuddy server must be on the same local network
 - Ports used: 8883 (MQTT/TLS), 990 (FTPS)
 
 ### Supported Printers
@@ -203,8 +203,8 @@ Since I only have X1C and H2D devices, I'm not able to test the application with
 
 ```bash
 # Clone the repository
-git clone https://github.com/maziggy/bambusy.git
-cd bambusy
+git clone https://github.com/maziggy/bambuddy.git
+cd bambuddy
 
 # Create and activate virtual environment
 python3 -m venv venv
@@ -246,8 +246,8 @@ sudo apt install python3 python3-venv python3-pip nodejs npm git
 #### Step 2: Clone the Repository
 
 ```bash
-git clone https://github.com/maziggy/bambusy.git
-cd bambusy
+git clone https://github.com/maziggy/bambuddy.git
+cd bambuddy
 ```
 
 #### Step 3: Set Up Python Environment
@@ -300,22 +300,22 @@ Open http://localhost:8000 in your browser.
 Create a systemd service for automatic startup:
 
 ```bash
-sudo nano /etc/systemd/system/bambusy.service
+sudo nano /etc/systemd/system/bambuddy.service
 ```
 
 Add the following content (adjust paths as needed):
 
 ```ini
 [Unit]
-Description=Bambusy Print Archive
+Description=Bambuddy Print Archive
 After=network.target
 
 [Service]
 Type=simple
 User=YOUR_USERNAME
-WorkingDirectory=/home/YOUR_USERNAME/bambusy
-Environment="PATH=/home/YOUR_USERNAME/bambusy/venv/bin"
-ExecStart=/home/YOUR_USERNAME/bambusy/venv/bin/uvicorn backend.app.main:app --host 0.0.0.0 --port 8000
+WorkingDirectory=/home/YOUR_USERNAME/bambuddy
+Environment="PATH=/home/YOUR_USERNAME/bambuddy/venv/bin"
+ExecStart=/home/YOUR_USERNAME/bambuddy/venv/bin/uvicorn backend.app.main:app --host 0.0.0.0 --port 8000
 Restart=always
 RestartSec=10
 
@@ -327,31 +327,31 @@ Enable and start the service:
 
 ```bash
 sudo systemctl daemon-reload
-sudo systemctl enable bambusy
-sudo systemctl start bambusy
+sudo systemctl enable bambuddy
+sudo systemctl start bambuddy
 
 # Check status
-sudo systemctl status bambusy
+sudo systemctl status bambuddy
 
 # View logs
-sudo journalctl -u bambusy -f
+sudo journalctl -u bambuddy -f
 ```
 
 ### Running with Docker (Coming Soon)
 
 ```bash
 docker run -d \
-  --name bambusy \
+  --name bambuddy \
   -p 8000:8000 \
-  -v bambusy_data:/app/data \
-  -v bambusy_archive:/app/archive \
-  maziggy/bambusy:latest
+  -v bambuddy_data:/app/data \
+  -v bambuddy_archive:/app/archive \
+  maziggy/bambuddy:latest
 ```
 
-### Updating Bambusy
+### Updating Bambuddy
 
 ```bash
-cd bambusy
+cd bambuddy
 git pull origin main
 
 # Activate virtual environment
@@ -374,7 +374,7 @@ cd ..
 
 ### Enabling LAN Mode on Your Printer
 
-To connect Bambusy to your printer, you need to enable LAN Mode:
+To connect Bambuddy to your printer, you need to enable LAN Mode:
 
 1. On your printer, go to **Settings** > **Network** > **LAN Mode**
 2. Enable **LAN Mode** (this requires Developer Mode to be enabled first)
@@ -382,7 +382,7 @@ To connect Bambusy to your printer, you need to enable LAN Mode:
 4. Find your printer's **IP Address** in network settings
 5. Find your printer's **Serial Number** in device info
 
-### Adding a Printer in Bambusy
+### Adding a Printer in Bambuddy
 
 1. Go to the **Printers** page
 2. Click **Add Printer**
@@ -397,7 +397,7 @@ The printer should connect automatically and show real-time status.
 
 ### Environment Variables
 
-Bambusy can be configured using environment variables or a `.env` file in the project root. Copy `.env.example` to `.env` and adjust as needed:
+Bambuddy can be configured using environment variables or a `.env` file in the project root. Copy `.env.example` to `.env` and adjust as needed:
 
 ```bash
 cp .env.example .env
@@ -407,12 +407,12 @@ cp .env.example .env
 |----------|---------|-------------|
 | `DEBUG` | `false` | Enable debug mode (verbose logging, SQL queries) |
 | `LOG_LEVEL` | `INFO` | Log level when DEBUG=false (`DEBUG`, `INFO`, `WARNING`, `ERROR`) |
-| `LOG_TO_FILE` | `true` | Write logs to `logs/bambutrack.log` |
+| `LOG_TO_FILE` | `true` | Write logs to `logs/bambuddy.log` |
 
 **Production (default):**
 - INFO level logging
 - SQLAlchemy and HTTP library noise suppressed
-- Logs written to `logs/bambutrack.log` (5MB rotating, 3 backups)
+- Logs written to `logs/bambuddy.log` (5MB rotating, 3 backups)
 
 **Development (`DEBUG=true`):**
 - DEBUG level logging (verbose)
@@ -499,7 +499,7 @@ When a scheduled print is ready to start:
 
 ### K-Profiles (Pressure Advance)
 
-K-profiles store pressure advance (Linear Advance) settings for different filament and nozzle combinations. Bambusy lets you view and manage these settings directly on your printers.
+K-profiles store pressure advance (Linear Advance) settings for different filament and nozzle combinations. Bambuddy lets you view and manage these settings directly on your printers.
 
 #### Viewing K-Profiles
 
@@ -513,7 +513,7 @@ K-profiles store pressure advance (Linear Advance) settings for different filame
 
 #### Dual-Nozzle Printers (H2 Series)
 
-For dual-nozzle printers (H2D, H2C, H2S), Bambusy automatically detects the nozzle configuration and displays:
+For dual-nozzle printers (H2D, H2C, H2S), Bambuddy automatically detects the nozzle configuration and displays:
 - **Left/Right columns** showing profiles for each extruder
 - **Extruder filter** to show profiles for one extruder only
 - **Extruder selector** when adding new profiles
@@ -535,7 +535,7 @@ The nozzle count is auto-detected from MQTT temperature data when the printer co
 4. For dual-nozzle printers, select Left or Right extruder
 5. Enter the K-value and click **Save**
 
-**Note:** Filaments must first be calibrated in Bambu Studio to appear in the dropdown. Bambusy reads the filament list from existing K-profiles on the printer.
+**Note:** Filaments must first be calibrated in Bambu Studio to appear in the dropdown. Bambuddy reads the filament list from existing K-profiles on the printer.
 
 #### Filtering and Search
 
@@ -545,7 +545,7 @@ The nozzle count is auto-detected from MQTT temperature data when the printer co
 
 ### Smart Plug Integration
 
-Bambusy supports Tasmota-based smart plugs for automated power control. This is useful for:
+Bambuddy supports Tasmota-based smart plugs for automated power control. This is useful for:
 - Automatically turning on your printer when a print starts
 - Safely turning off the printer after it cools down
 - Energy savings by powering off idle printers
@@ -597,7 +597,7 @@ Use cases:
 
 #### Power Monitoring & Alerts
 
-For Tasmota plugs with energy monitoring (e.g., Sonoff S31), Bambusy can alert you when power consumption exceeds a threshold:
+For Tasmota plugs with energy monitoring (e.g., Sonoff S31), Bambuddy can alert you when power consumption exceeds a threshold:
 
 1. Enable **Power Alert** in the plug settings
 2. Set the **Power Threshold** in watts (e.g., 200W)
@@ -618,7 +618,7 @@ Each plug card shows:
 
 ### Push Notifications
 
-Bambusy can send push notifications when print events occur. Notifications are useful for monitoring prints remotely without checking the app constantly.
+Bambuddy can send push notifications when print events occur. Notifications are useful for monitoring prints remotely without checking the app constantly.
 
 #### Supported Providers
 
@@ -713,7 +713,7 @@ Common variables available for all events: `{timestamp}`, `{app_name}`
 
 ### Spoolman Integration
 
-Bambusy integrates with [Spoolman](https://github.com/Donkie/Spoolman) for filament inventory management. When enabled, AMS filament data syncs with your Spoolman server, allowing you to track remaining filament across all your spools.
+Bambuddy integrates with [Spoolman](https://github.com/Donkie/Spoolman) for filament inventory management. When enabled, AMS filament data syncs with your Spoolman server, allowing you to track remaining filament across all your spools.
 
 #### Prerequisites
 
@@ -745,7 +745,7 @@ When connected:
 
 #### How Syncing Works
 
-Bambusy matches AMS spools to Spoolman spools using the **tray UUID** - a unique 32-character identifier that Bambu Lab assigns to each original spool. This ensures consistent matching across different printer models.
+Bambuddy matches AMS spools to Spoolman spools using the **tray UUID** - a unique 32-character identifier that Bambu Lab assigns to each original spool. This ensures consistent matching across different printer models.
 
 **What gets synced:**
 - Remaining filament weight (from AMS sensor)
@@ -768,7 +768,7 @@ Bambusy matches AMS spools to Spoolman spools using the **tray UUID** - a unique
 - This is normal behavior - they don't have Bambu Lab tray UUIDs
 
 **Connection issues:**
-- Verify the Spoolman URL is accessible from your Bambusy server
+- Verify the Spoolman URL is accessible from your Bambuddy server
 - Check that no firewall is blocking port 7912 (or your custom port)
 - Ensure Spoolman is running and healthy
 
@@ -778,22 +778,22 @@ Bambusy matches AMS spools to Spoolman spools using the **tray UUID** - a unique
 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
+4. Enter your phone number (with country code) and API key in Bambuddy
 
 **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)
+3. Enter the topic name in Bambuddy (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
+3. Enter your user key and app token in Bambuddy
 
 **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
+3. Enter the bot token and chat ID in Bambuddy
 
 **Email:**
 1. Configure your SMTP server settings
@@ -805,12 +805,12 @@ Bambusy matches AMS spools to Spoolman spools using the **tray UUID** - a unique
 1. In your Discord server, go to channel settings > Integrations > Webhooks
 2. Click "New Webhook" and customize the name/avatar if desired
 3. Copy the webhook URL
-4. Paste the webhook URL in Bambusy
+4. Paste the webhook URL in Bambuddy
 
 **Webhook (Generic):**
 1. Enter any URL that accepts POST requests
 2. Optionally add custom headers (e.g., Authorization tokens)
-3. Bambusy sends JSON payloads with event details
+3. Bambuddy sends JSON payloads with event details
 4. Useful for integrating with custom systems, Home Assistant, IFTTT, etc.
 
 ## Tech Stack
@@ -824,7 +824,7 @@ Bambusy matches AMS spools to Spoolman spools using the **tray UUID** - a unique
 ## Project Structure
 
 ```
-bambusy/
+bambuddy/
 ├── backend/
 │   └── app/
 │       ├── api/routes/      # API endpoints
@@ -835,7 +835,7 @@ bambusy/
 ├── frontend/                # React application
 ├── static/                  # Built frontend + images
 ├── archive/                 # Stored 3MF files
-└── bambusy.db              # SQLite database
+└── bambuddy.db              # SQLite database
 ```
 
 ## API Documentation
@@ -884,27 +884,27 @@ Contributions are welcome! Please feel free to submit a Pull Request.
 
 ### Database errors
 
-The SQLite database (`bambusy.db`) is created automatically. If you encounter issues:
+The SQLite database (`bambuddy.db`) is created automatically. If you encounter issues:
 
 ```bash
 # Backup and reset database
-mv bambusy.db bambusy.db.backup
+mv bambuddy.db bambuddy.db.backup
 # Restart the application - a new database will be created
 ```
 
 ### View server logs
 
-Bambusy writes logs to `bambutrack.log` in the application directory (rotating, max 5MB × 3 files).
+Bambuddy writes logs to `bambuddy.log` in the application directory (rotating, max 5MB × 3 files).
 
 ```bash
 # View live log file
-tail -f bambutrack.log
+tail -f bambuddy.log
 
 # If running directly with verbose output
 uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 --log-level debug
 
 # If running as systemd service
-sudo journalctl -u bambusy -f
+sudo journalctl -u bambuddy -f
 ```
 
 ### Smart plug not responding
@@ -913,13 +913,13 @@ sudo journalctl -u bambusy -f
 2. **Test via browser** - Visit `http://<plug-ip>/cm?cmnd=Power` to test directly
 3. **Check Tasmota web interface** - Access `http://<plug-ip>` to verify Tasmota is running
 4. **Authentication** - If Tasmota has a password set, configure it in the plug settings
-5. **Firewall** - Ensure port 80 is accessible between Bambusy server and the plug
+5. **Firewall** - Ensure port 80 is accessible between Bambuddy server and the plug
 
 ### Auto power-off not working
 
 1. **Check plug is linked** - The plug must be linked to a printer for automation
 2. **Verify automation is enabled** - Check the Enabled, Auto On, and Auto Off toggles
-3. **Temperature mode issues** - If using temperature mode, ensure the printer is still connected so Bambusy can read the nozzle temperature
+3. **Temperature mode issues** - If using temperature mode, ensure the printer is still connected so Bambuddy can read the nozzle temperature
 
 ### Scheduled print not starting
 
@@ -927,7 +927,7 @@ sudo journalctl -u bambusy -f
 2. **Verify smart plug** - If using auto power-on, ensure the smart plug is configured and working
 3. **Check queue status** - Look at the queue page for error messages
 4. **Time zone issues** - Scheduled times are in your local time zone; ensure your system clock is correct
-5. **View logs** - Check `bambutrack.log` for detailed error messages
+5. **View logs** - Check `bambuddy.log` for detailed error messages
 
 ### Print queue shows "Failed to start"
 
@@ -939,7 +939,7 @@ Common causes:
 ### Timelapse not attaching automatically
 
 **The Problem:**
-When printers run in **LAN-only mode** (disconnected from Bambu Cloud), they cannot sync time via NTP. This causes the printer's internal clock to drift significantly (sometimes days or weeks off). Bambusy matches timelapses by comparing the print completion time with the timelapse file's modification time - when the printer's clock is wrong, this matching fails.
+When printers run in **LAN-only mode** (disconnected from Bambu Cloud), they cannot sync time via NTP. This causes the printer's internal clock to drift significantly (sometimes days or weeks off). Bambuddy matches timelapses by comparing the print completion time with the timelapse file's modification time - when the printer's clock is wrong, this matching fails.
 
 **Symptoms:**
 - "Scan for Timelapse" shows "No matching timelapse found"
@@ -947,7 +947,7 @@ When printers run in **LAN-only mode** (disconnected from Bambu Cloud), they can
 - Printer shows incorrect date/time in its settings
 
 **Workaround - Manual Selection:**
-When automatic matching fails, Bambusy now offers manual timelapse selection:
+When automatic matching fails, Bambuddy now offers manual timelapse selection:
 
 1. Right-click the archive and select **"Scan for Timelapse"**
 2. If no match is found, a dialog appears showing all available timelapse files on the printer

+ 711 - 66
backend/app/api/routes/settings.py

@@ -1,16 +1,28 @@
+import io
 import json
+import zipfile
 from datetime import datetime
+from pathlib import Path
+from typing import Optional
 
-from fastapi import APIRouter, Depends, UploadFile, File
-from fastapi.responses import JSONResponse
+from fastapi import APIRouter, Depends, UploadFile, File, Query
+from fastapi.responses import JSONResponse, StreamingResponse
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy import select
 
+from backend.app.core.config import settings as app_settings
 from backend.app.core.database import get_db
 from backend.app.models.settings import Settings
 from backend.app.models.notification import NotificationProvider
+from backend.app.models.notification_template import NotificationTemplate
 from backend.app.models.smart_plug import SmartPlug
+from backend.app.models.printer import Printer
+from backend.app.models.filament import Filament
+from backend.app.models.maintenance import MaintenanceType, PrinterMaintenance, MaintenanceHistory
+from backend.app.models.archive import PrintArchive
 from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
+from backend.app.services.printer_manager import printer_manager
+from backend.app.services.spoolman import init_spoolman_client, get_spoolman_client
 
 
 router = APIRouter(prefix="/settings", tags=["settings"])
@@ -149,62 +161,272 @@ async def update_spoolman_settings(
 
 
 @router.get("/backup")
-async def export_backup(db: AsyncSession = Depends(get_db)):
-    """Export all settings, notification providers, and smart plugs as JSON backup."""
-    # Get all settings
-    result = await db.execute(select(Settings))
-    db_settings = result.scalars().all()
-    settings_data = {s.key: s.value for s in db_settings}
-
-    # Get notification providers
-    result = await db.execute(select(NotificationProvider))
-    providers = result.scalars().all()
-    providers_data = []
-    for p in providers:
-        providers_data.append({
-            "name": p.name,
-            "provider_type": p.provider_type,
-            "enabled": p.enabled,
-            "config": json.loads(p.config) if isinstance(p.config, str) else p.config,
-            "on_print_start": p.on_print_start,
-            "on_print_complete": p.on_print_complete,
-            "on_print_failed": p.on_print_failed,
-            "on_print_stopped": p.on_print_stopped,
-            "on_print_progress": p.on_print_progress,
-            "on_printer_offline": p.on_printer_offline,
-            "on_printer_error": p.on_printer_error,
-            "on_filament_low": p.on_filament_low,
-            "on_maintenance_due": p.on_maintenance_due,
-            "quiet_hours_enabled": p.quiet_hours_enabled,
-            "quiet_hours_start": p.quiet_hours_start,
-            "quiet_hours_end": p.quiet_hours_end,
-        })
-
-    # Get smart plugs
-    result = await db.execute(select(SmartPlug))
-    plugs = result.scalars().all()
-    plugs_data = []
-    for plug in plugs:
-        plugs_data.append({
-            "name": plug.name,
-            "ip_address": plug.ip_address,
-            "enabled": plug.enabled,
-            "auto_off_enabled": plug.auto_off_enabled,
-            "auto_off_delay_minutes": plug.auto_off_delay_minutes,
-        })
-
-    backup = {
-        "version": "1.0",
+async def export_backup(
+    db: AsyncSession = Depends(get_db),
+    include_settings: bool = Query(True, description="Include app settings"),
+    include_notifications: bool = Query(True, description="Include notification providers"),
+    include_templates: bool = Query(True, description="Include notification templates"),
+    include_smart_plugs: bool = Query(True, description="Include smart plugs"),
+    include_printers: bool = Query(False, description="Include printers (without access codes)"),
+    include_filaments: bool = Query(False, description="Include filament inventory"),
+    include_maintenance: bool = Query(False, description="Include maintenance types and records"),
+    include_archives: bool = Query(False, description="Include print archive metadata"),
+    include_access_codes: bool = Query(False, description="Include printer access codes (security risk!)"),
+):
+    """Export selected data as JSON backup."""
+    backup: dict = {
+        "version": "2.0",
         "exported_at": datetime.utcnow().isoformat(),
-        "settings": settings_data,
-        "notification_providers": providers_data,
-        "smart_plugs": plugs_data,
+        "included": [],
     }
 
+    # Settings
+    if include_settings:
+        result = await db.execute(select(Settings))
+        db_settings = result.scalars().all()
+        backup["settings"] = {s.key: s.value for s in db_settings}
+        backup["included"].append("settings")
+
+    # Notification providers
+    if include_notifications:
+        result = await db.execute(select(NotificationProvider))
+        providers = result.scalars().all()
+        backup["notification_providers"] = []
+        for p in providers:
+            backup["notification_providers"].append({
+                "name": p.name,
+                "provider_type": p.provider_type,
+                "enabled": p.enabled,
+                "config": json.loads(p.config) if isinstance(p.config, str) else p.config,
+                "on_print_start": p.on_print_start,
+                "on_print_complete": p.on_print_complete,
+                "on_print_failed": p.on_print_failed,
+                "on_print_stopped": p.on_print_stopped,
+                "on_print_progress": p.on_print_progress,
+                "on_printer_offline": p.on_printer_offline,
+                "on_printer_error": p.on_printer_error,
+                "on_filament_low": p.on_filament_low,
+                "on_maintenance_due": p.on_maintenance_due,
+                "quiet_hours_enabled": p.quiet_hours_enabled,
+                "quiet_hours_start": p.quiet_hours_start,
+                "quiet_hours_end": p.quiet_hours_end,
+                "daily_digest_enabled": getattr(p, 'daily_digest_enabled', False),
+                "daily_digest_time": getattr(p, 'daily_digest_time', None),
+                "printer_id": getattr(p, 'printer_id', None),
+            })
+        backup["included"].append("notification_providers")
+
+    # Notification templates
+    if include_templates:
+        result = await db.execute(select(NotificationTemplate))
+        templates = result.scalars().all()
+        backup["notification_templates"] = []
+        for t in templates:
+            backup["notification_templates"].append({
+                "event_type": t.event_type,
+                "name": t.name,
+                "title_template": t.title_template,
+                "body_template": t.body_template,
+                "is_default": t.is_default,
+            })
+        backup["included"].append("notification_templates")
+
+    # Smart plugs
+    if include_smart_plugs:
+        result = await db.execute(select(SmartPlug))
+        plugs = result.scalars().all()
+        backup["smart_plugs"] = []
+        for plug in plugs:
+            backup["smart_plugs"].append({
+                "name": plug.name,
+                "ip_address": plug.ip_address,
+                "printer_id": plug.printer_id,
+                "enabled": plug.enabled,
+                "auto_on": plug.auto_on,
+                "auto_off": plug.auto_off,
+                "off_delay_mode": plug.off_delay_mode,
+                "off_delay_minutes": plug.off_delay_minutes,
+                "off_temp_threshold": plug.off_temp_threshold,
+                "username": plug.username,
+                "password": plug.password,
+                "power_alert_enabled": plug.power_alert_enabled,
+                "power_alert_high": plug.power_alert_high,
+                "power_alert_low": plug.power_alert_low,
+                "schedule_enabled": plug.schedule_enabled,
+                "schedule_on_time": plug.schedule_on_time,
+                "schedule_off_time": plug.schedule_off_time,
+            })
+        backup["included"].append("smart_plugs")
+
+    # Printers (access codes only included if explicitly requested)
+    if include_printers:
+        result = await db.execute(select(Printer))
+        printers = result.scalars().all()
+        backup["printers"] = []
+        for printer in printers:
+            printer_data = {
+                "name": printer.name,
+                "serial_number": printer.serial_number,
+                "ip_address": printer.ip_address,
+                "model": printer.model,
+                "location": printer.location,
+                "nozzle_count": printer.nozzle_count,
+                "is_active": printer.is_active,
+                "auto_archive": printer.auto_archive,
+                "print_hours_offset": printer.print_hours_offset,
+            }
+            if include_access_codes:
+                printer_data["access_code"] = printer.access_code
+            backup["printers"].append(printer_data)
+        backup["included"].append("printers")
+        if include_access_codes:
+            backup["included"].append("access_codes")
+
+    # Filaments
+    if include_filaments:
+        result = await db.execute(select(Filament))
+        filaments = result.scalars().all()
+        backup["filaments"] = []
+        for f in filaments:
+            backup["filaments"].append({
+                "name": f.name,
+                "type": f.type,
+                "brand": f.brand,
+                "color": f.color,
+                "color_hex": f.color_hex,
+                "cost_per_kg": f.cost_per_kg,
+                "spool_weight_g": f.spool_weight_g,
+                "currency": f.currency,
+                "density": f.density,
+                "print_temp_min": f.print_temp_min,
+                "print_temp_max": f.print_temp_max,
+                "bed_temp_min": f.bed_temp_min,
+                "bed_temp_max": f.bed_temp_max,
+            })
+        backup["included"].append("filaments")
+
+    # Maintenance types and records
+    if include_maintenance:
+        # Maintenance types
+        result = await db.execute(select(MaintenanceType))
+        types = result.scalars().all()
+        backup["maintenance_types"] = []
+        for mt in types:
+            backup["maintenance_types"].append({
+                "name": mt.name,
+                "description": mt.description,
+                "default_interval_hours": mt.default_interval_hours,
+                "interval_type": mt.interval_type,
+                "icon": mt.icon,
+                "is_system": mt.is_system,
+            })
+        backup["included"].append("maintenance_types")
+
+    # Print archives with file paths for ZIP
+    archive_files: list[tuple[str, Path]] = []  # (zip_path, local_path)
+    if include_archives:
+        result = await db.execute(select(PrintArchive))
+        archives = result.scalars().all()
+        backup["archives"] = []
+        base_dir = app_settings.base_dir
+
+        for a in archives:
+            archive_data = {
+                "filename": a.filename,
+                "file_size": a.file_size,
+                "content_hash": a.content_hash,
+                "print_name": a.print_name,
+                "print_time_seconds": a.print_time_seconds,
+                "filament_used_grams": a.filament_used_grams,
+                "filament_type": a.filament_type,
+                "filament_color": a.filament_color,
+                "layer_height": a.layer_height,
+                "total_layers": a.total_layers,
+                "nozzle_diameter": a.nozzle_diameter,
+                "bed_temperature": a.bed_temperature,
+                "nozzle_temperature": a.nozzle_temperature,
+                "status": a.status,
+                "started_at": a.started_at.isoformat() if a.started_at else None,
+                "completed_at": a.completed_at.isoformat() if a.completed_at else None,
+                "makerworld_url": a.makerworld_url,
+                "designer": a.designer,
+                "is_favorite": a.is_favorite,
+                "tags": a.tags,
+                "notes": a.notes,
+                "cost": a.cost,
+                "failure_reason": a.failure_reason,
+                "energy_kwh": a.energy_kwh,
+                "energy_cost": a.energy_cost,
+                "extra_data": a.extra_data,
+                "photos": a.photos,
+            }
+
+            # Collect file paths for ZIP
+            if a.file_path:
+                file_path = base_dir / a.file_path
+                if file_path.exists():
+                    archive_data["file_path"] = a.file_path
+                    archive_files.append((a.file_path, file_path))
+
+            if a.thumbnail_path:
+                thumb_path = base_dir / a.thumbnail_path
+                if thumb_path.exists():
+                    archive_data["thumbnail_path"] = a.thumbnail_path
+                    archive_files.append((a.thumbnail_path, thumb_path))
+
+            if a.timelapse_path:
+                timelapse_path = base_dir / a.timelapse_path
+                if timelapse_path.exists():
+                    archive_data["timelapse_path"] = a.timelapse_path
+                    archive_files.append((a.timelapse_path, timelapse_path))
+
+            if a.source_3mf_path:
+                source_path = base_dir / a.source_3mf_path
+                if source_path.exists():
+                    archive_data["source_3mf_path"] = a.source_3mf_path
+                    archive_files.append((a.source_3mf_path, source_path))
+
+            # Include photos
+            if a.photos:
+                for photo in a.photos:
+                    photo_path = base_dir / "archive" / "photos" / photo
+                    if photo_path.exists():
+                        zip_photo_path = f"archive/photos/{photo}"
+                        archive_files.append((zip_photo_path, photo_path))
+
+            backup["archives"].append(archive_data)
+        backup["included"].append("archives")
+
+    # If archives included, create ZIP file with all files
+    if include_archives and archive_files:
+        zip_buffer = io.BytesIO()
+        with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
+            # Add backup.json
+            zf.writestr("backup.json", json.dumps(backup, indent=2))
+
+            # Add all archive files
+            added_files = set()
+            for zip_path, local_path in archive_files:
+                if zip_path not in added_files and local_path.exists():
+                    try:
+                        zf.write(local_path, zip_path)
+                        added_files.add(zip_path)
+                    except Exception:
+                        pass  # Skip files that can't be read
+
+        zip_buffer.seek(0)
+        filename = f"bambuddy-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip"
+        return StreamingResponse(
+            zip_buffer,
+            media_type="application/zip",
+            headers={"Content-Disposition": f"attachment; filename={filename}"}
+        )
+
+    # Otherwise return JSON
     return JSONResponse(
         content=backup,
         headers={
-            "Content-Disposition": f"attachment; filename=bambutrack-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.json"
+            "Content-Disposition": f"attachment; filename=bambuddy-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.json"
         }
     )
 
@@ -212,32 +434,134 @@ async def export_backup(db: AsyncSession = Depends(get_db)):
 @router.post("/restore")
 async def import_backup(
     file: UploadFile = File(...),
+    overwrite: bool = Query(False, description="Overwrite existing data instead of skipping duplicates"),
     db: AsyncSession = Depends(get_db),
 ):
-    """Restore settings, notification providers, and smart plugs from JSON backup."""
+    """Restore data from JSON or ZIP backup. By default skips duplicates, set overwrite=true to replace existing."""
     try:
         content = await file.read()
-        backup = json.loads(content.decode("utf-8"))
+        base_dir = app_settings.base_dir
+        files_restored = 0
+
+        # Check if it's a ZIP file
+        if file.filename and file.filename.endswith('.zip'):
+            try:
+                zip_buffer = io.BytesIO(content)
+                with zipfile.ZipFile(zip_buffer, 'r') as zf:
+                    # Extract backup.json
+                    if 'backup.json' not in zf.namelist():
+                        return {"success": False, "message": "Invalid ZIP: missing backup.json"}
+
+                    backup_content = zf.read('backup.json')
+                    backup = json.loads(backup_content.decode("utf-8"))
+
+                    # Extract all other files to base_dir
+                    for zip_path in zf.namelist():
+                        if zip_path == 'backup.json':
+                            continue
+                        # Ensure path is safe (no path traversal)
+                        if '..' in zip_path or zip_path.startswith('/'):
+                            continue
+                        target_path = base_dir / zip_path
+                        target_path.parent.mkdir(parents=True, exist_ok=True)
+                        with zf.open(zip_path) as src, open(target_path, 'wb') as dst:
+                            dst.write(src.read())
+                            files_restored += 1
+            except zipfile.BadZipFile:
+                return {"success": False, "message": "Invalid ZIP file"}
+        else:
+            backup = json.loads(content.decode("utf-8"))
+    except json.JSONDecodeError as e:
+        return {"success": False, "message": f"Invalid JSON: {str(e)}"}
     except Exception as e:
         return {"success": False, "message": f"Invalid backup file: {str(e)}"}
 
-    restored = {"settings": 0, "notification_providers": 0, "smart_plugs": 0}
+    restored = {
+        "settings": 0,
+        "notification_providers": 0,
+        "notification_templates": 0,
+        "smart_plugs": 0,
+        "printers": 0,
+        "filaments": 0,
+        "maintenance_types": 0,
+    }
+    skipped = {
+        "settings": 0,
+        "notification_providers": 0,
+        "notification_templates": 0,
+        "smart_plugs": 0,
+        "printers": 0,
+        "filaments": 0,
+        "maintenance_types": 0,
+        "archives": 0,
+    }
+    skipped_details = {
+        "notification_providers": [],
+        "smart_plugs": [],
+        "printers": [],
+        "filaments": [],
+        "maintenance_types": [],
+        "archives": [],
+    }
 
-    # Restore settings
+    # Log what's in the backup
+    import logging
+    restore_logger = logging.getLogger(__name__)
+    restore_logger.info(f"Restore: Backup version={backup.get('version')}, included={backup.get('included', [])}")
+    restore_logger.info(f"Restore: overwrite={overwrite}")
+    if "printers" in backup:
+        restore_logger.info(f"Restore: Backup contains {len(backup['printers'])} printers")
+        for p in backup["printers"]:
+            restore_logger.info(f"  - {p.get('name')}: access_code={'YES' if p.get('access_code') else 'NO'}, is_active={p.get('is_active')}")
+    else:
+        restore_logger.info("Restore: Backup does NOT contain printers")
+
+    # Restore settings (always overwrites)
     if "settings" in backup:
         for key, value in backup["settings"].items():
-            await set_setting(db, key, value)
+            # Convert value to proper string format for storage
+            if isinstance(value, bool):
+                str_value = "true" if value else "false"
+            elif value is None:
+                str_value = "None"
+            else:
+                str_value = str(value)
+            await set_setting(db, key, str_value)
             restored["settings"] += 1
 
-    # Restore notification providers (skip duplicates by name)
+    # Restore notification providers (skip or overwrite duplicates by name)
     if "notification_providers" in backup:
         for provider_data in backup["notification_providers"]:
-            # Check if provider with same name exists
             result = await db.execute(
                 select(NotificationProvider).where(NotificationProvider.name == provider_data["name"])
             )
             existing = result.scalar_one_or_none()
-            if not existing:
+            if existing:
+                if overwrite:
+                    # Update existing provider
+                    existing.provider_type = provider_data["provider_type"]
+                    existing.enabled = provider_data.get("enabled", True)
+                    existing.config = json.dumps(provider_data.get("config", {}))
+                    existing.on_print_start = provider_data.get("on_print_start", False)
+                    existing.on_print_complete = provider_data.get("on_print_complete", True)
+                    existing.on_print_failed = provider_data.get("on_print_failed", True)
+                    existing.on_print_stopped = provider_data.get("on_print_stopped", True)
+                    existing.on_print_progress = provider_data.get("on_print_progress", False)
+                    existing.on_printer_offline = provider_data.get("on_printer_offline", False)
+                    existing.on_printer_error = provider_data.get("on_printer_error", False)
+                    existing.on_filament_low = provider_data.get("on_filament_low", False)
+                    existing.on_maintenance_due = provider_data.get("on_maintenance_due", False)
+                    existing.quiet_hours_enabled = provider_data.get("quiet_hours_enabled", False)
+                    existing.quiet_hours_start = provider_data.get("quiet_hours_start")
+                    existing.quiet_hours_end = provider_data.get("quiet_hours_end")
+                    existing.daily_digest_enabled = provider_data.get("daily_digest_enabled", False)
+                    existing.daily_digest_time = provider_data.get("daily_digest_time")
+                    existing.printer_id = provider_data.get("printer_id")
+                    restored["notification_providers"] += 1
+                else:
+                    skipped["notification_providers"] += 1
+                    skipped_details["notification_providers"].append(provider_data["name"])
+            else:
                 provider = NotificationProvider(
                     name=provider_data["name"],
                     provider_type=provider_data["provider_type"],
@@ -255,33 +579,354 @@ async def import_backup(
                     quiet_hours_enabled=provider_data.get("quiet_hours_enabled", False),
                     quiet_hours_start=provider_data.get("quiet_hours_start"),
                     quiet_hours_end=provider_data.get("quiet_hours_end"),
+                    daily_digest_enabled=provider_data.get("daily_digest_enabled", False),
+                    daily_digest_time=provider_data.get("daily_digest_time"),
+                    printer_id=provider_data.get("printer_id"),
                 )
                 db.add(provider)
                 restored["notification_providers"] += 1
 
-    # Restore smart plugs (skip duplicates by IP)
+    # Restore notification templates (update existing by event_type)
+    if "notification_templates" in backup:
+        for template_data in backup["notification_templates"]:
+            result = await db.execute(
+                select(NotificationTemplate).where(
+                    NotificationTemplate.event_type == template_data["event_type"]
+                )
+            )
+            existing = result.scalar_one_or_none()
+            if existing:
+                # Update existing template
+                existing.name = template_data.get("name", existing.name)
+                existing.title_template = template_data.get("title_template", existing.title_template)
+                existing.body_template = template_data.get("body_template", existing.body_template)
+                existing.is_default = template_data.get("is_default", False)
+            else:
+                template = NotificationTemplate(
+                    event_type=template_data["event_type"],
+                    name=template_data["name"],
+                    title_template=template_data["title_template"],
+                    body_template=template_data["body_template"],
+                    is_default=template_data.get("is_default", False),
+                )
+                db.add(template)
+            restored["notification_templates"] += 1
+
+    # Restore smart plugs (skip or overwrite duplicates by IP)
     if "smart_plugs" in backup:
         for plug_data in backup["smart_plugs"]:
-            # Check if plug with same IP exists
             result = await db.execute(
                 select(SmartPlug).where(SmartPlug.ip_address == plug_data["ip_address"])
             )
             existing = result.scalar_one_or_none()
-            if not existing:
+            if existing:
+                if overwrite:
+                    existing.name = plug_data["name"]
+                    existing.printer_id = plug_data.get("printer_id")
+                    existing.enabled = plug_data.get("enabled", True)
+                    existing.auto_on = plug_data.get("auto_on", True)
+                    existing.auto_off = plug_data.get("auto_off", True)
+                    existing.off_delay_mode = plug_data.get("off_delay_mode", "time")
+                    existing.off_delay_minutes = plug_data.get("off_delay_minutes", 5)
+                    existing.off_temp_threshold = plug_data.get("off_temp_threshold", 70)
+                    existing.username = plug_data.get("username")
+                    existing.password = plug_data.get("password")
+                    existing.power_alert_enabled = plug_data.get("power_alert_enabled", False)
+                    existing.power_alert_high = plug_data.get("power_alert_high")
+                    existing.power_alert_low = plug_data.get("power_alert_low")
+                    existing.schedule_enabled = plug_data.get("schedule_enabled", False)
+                    existing.schedule_on_time = plug_data.get("schedule_on_time")
+                    existing.schedule_off_time = plug_data.get("schedule_off_time")
+                    restored["smart_plugs"] += 1
+                else:
+                    skipped["smart_plugs"] += 1
+                    skipped_details["smart_plugs"].append(f"{plug_data['name']} ({plug_data['ip_address']})")
+            else:
                 plug = SmartPlug(
                     name=plug_data["name"],
                     ip_address=plug_data["ip_address"],
+                    printer_id=plug_data.get("printer_id"),
                     enabled=plug_data.get("enabled", True),
-                    auto_off_enabled=plug_data.get("auto_off_enabled", False),
-                    auto_off_delay_minutes=plug_data.get("auto_off_delay_minutes", 5),
+                    auto_on=plug_data.get("auto_on", True),
+                    auto_off=plug_data.get("auto_off", True),
+                    off_delay_mode=plug_data.get("off_delay_mode", "time"),
+                    off_delay_minutes=plug_data.get("off_delay_minutes", 5),
+                    off_temp_threshold=plug_data.get("off_temp_threshold", 70),
+                    username=plug_data.get("username"),
+                    password=plug_data.get("password"),
+                    power_alert_enabled=plug_data.get("power_alert_enabled", False),
+                    power_alert_high=plug_data.get("power_alert_high"),
+                    power_alert_low=plug_data.get("power_alert_low"),
+                    schedule_enabled=plug_data.get("schedule_enabled", False),
+                    schedule_on_time=plug_data.get("schedule_on_time"),
+                    schedule_off_time=plug_data.get("schedule_off_time"),
                 )
                 db.add(plug)
                 restored["smart_plugs"] += 1
 
+    # Restore printers (skip or overwrite duplicates by serial_number)
+    import logging
+    logger = logging.getLogger(__name__)
+
+    if "printers" in backup:
+        logger.info(f"Restore: Processing {len(backup['printers'])} printers from backup")
+        for printer_data in backup["printers"]:
+            logger.info(f"Restore: Processing printer {printer_data.get('name')} (serial: {printer_data.get('serial_number')})")
+            result = await db.execute(
+                select(Printer).where(Printer.serial_number == printer_data["serial_number"])
+            )
+            existing = result.scalar_one_or_none()
+            if existing:
+                logger.info(f"Restore: Printer already exists (id={existing.id}, is_active={existing.is_active})")
+                if overwrite:
+                    existing.name = printer_data["name"]
+                    existing.ip_address = printer_data["ip_address"]
+                    existing.model = printer_data.get("model")
+                    existing.location = printer_data.get("location")
+                    existing.nozzle_count = printer_data.get("nozzle_count", 1)
+                    existing.auto_archive = printer_data.get("auto_archive", True)
+                    existing.print_hours_offset = printer_data.get("print_hours_offset", 0.0)
+
+                    # If backup includes access_code, also update access_code and is_active
+                    backup_access_code = printer_data.get("access_code")
+                    if backup_access_code and backup_access_code != "CHANGE_ME":
+                        existing.access_code = backup_access_code
+                        is_active_val = printer_data.get("is_active", False)
+                        if isinstance(is_active_val, str):
+                            is_active_val = is_active_val.lower() == "true"
+                        existing.is_active = is_active_val
+                        logger.info(f"Restore: Updated access_code and is_active={is_active_val} from backup")
+
+                    restored["printers"] += 1
+                    logger.info(f"Restore: Updated existing printer (overwrite=True)")
+                else:
+                    skipped["printers"] += 1
+                    skipped_details["printers"].append(f"{printer_data['name']} ({printer_data['serial_number']})")
+                    logger.info(f"Restore: Skipped existing printer (overwrite=False)")
+            else:
+                # Use access code from backup if provided, otherwise require manual setup
+                access_code = printer_data.get("access_code")
+                has_access_code = access_code and access_code != "CHANGE_ME"
+                is_active_from_backup = printer_data.get("is_active", False)
+                # Handle bool or string "true"/"false"
+                if isinstance(is_active_from_backup, str):
+                    is_active_from_backup = is_active_from_backup.lower() == "true"
+
+                import logging
+                logger = logging.getLogger(__name__)
+                logger.info(f"Restore: Creating printer {printer_data['name']}")
+                logger.info(f"  - access_code in backup: {'YES' if 'access_code' in printer_data else 'NO'}")
+                logger.info(f"  - access_code value: {access_code[:4] + '...' if access_code and len(access_code) > 4 else access_code}")
+                logger.info(f"  - has_access_code (valid): {has_access_code}")
+                logger.info(f"  - is_active in backup: {printer_data.get('is_active')} (type: {type(printer_data.get('is_active')).__name__})")
+                logger.info(f"  - is_active_from_backup (converted): {is_active_from_backup}")
+                logger.info(f"  - final is_active: {is_active_from_backup if has_access_code else False}")
+
+                printer = Printer(
+                    name=printer_data["name"],
+                    serial_number=printer_data["serial_number"],
+                    ip_address=printer_data["ip_address"],
+                    access_code=access_code if has_access_code else "CHANGE_ME",
+                    model=printer_data.get("model"),
+                    location=printer_data.get("location"),
+                    nozzle_count=printer_data.get("nozzle_count", 1),
+                    is_active=is_active_from_backup if has_access_code else False,
+                    auto_archive=printer_data.get("auto_archive", True),
+                    print_hours_offset=printer_data.get("print_hours_offset", 0.0),
+                )
+                db.add(printer)
+                restored["printers"] += 1
+
+    # Restore filaments (skip or overwrite duplicates by name+type+brand)
+    if "filaments" in backup:
+        for filament_data in backup["filaments"]:
+            result = await db.execute(
+                select(Filament).where(
+                    Filament.name == filament_data["name"],
+                    Filament.type == filament_data["type"],
+                    Filament.brand == filament_data.get("brand"),
+                )
+            )
+            existing = result.scalar_one_or_none()
+            if existing:
+                if overwrite:
+                    existing.color = filament_data.get("color")
+                    existing.color_hex = filament_data.get("color_hex")
+                    existing.cost_per_kg = filament_data.get("cost_per_kg", 25.0)
+                    existing.spool_weight_g = filament_data.get("spool_weight_g", 1000.0)
+                    existing.currency = filament_data.get("currency", "USD")
+                    existing.density = filament_data.get("density")
+                    existing.print_temp_min = filament_data.get("print_temp_min")
+                    existing.print_temp_max = filament_data.get("print_temp_max")
+                    existing.bed_temp_min = filament_data.get("bed_temp_min")
+                    existing.bed_temp_max = filament_data.get("bed_temp_max")
+                    restored["filaments"] += 1
+                else:
+                    skipped["filaments"] += 1
+                    skipped_details["filaments"].append(f"{filament_data.get('brand', '')} {filament_data['name']} ({filament_data['type']})")
+            else:
+                filament = Filament(
+                    name=filament_data["name"],
+                    type=filament_data["type"],
+                    brand=filament_data.get("brand"),
+                    color=filament_data.get("color"),
+                    color_hex=filament_data.get("color_hex"),
+                    cost_per_kg=filament_data.get("cost_per_kg", 25.0),
+                    spool_weight_g=filament_data.get("spool_weight_g", 1000.0),
+                    currency=filament_data.get("currency", "USD"),
+                    density=filament_data.get("density"),
+                    print_temp_min=filament_data.get("print_temp_min"),
+                    print_temp_max=filament_data.get("print_temp_max"),
+                    bed_temp_min=filament_data.get("bed_temp_min"),
+                    bed_temp_max=filament_data.get("bed_temp_max"),
+                )
+                db.add(filament)
+                restored["filaments"] += 1
+
+    # Restore maintenance types (skip or overwrite duplicates by name)
+    if "maintenance_types" in backup:
+        for mt_data in backup["maintenance_types"]:
+            result = await db.execute(
+                select(MaintenanceType).where(MaintenanceType.name == mt_data["name"])
+            )
+            existing = result.scalar_one_or_none()
+            if existing:
+                if overwrite:
+                    existing.description = mt_data.get("description")
+                    existing.default_interval_hours = mt_data.get("default_interval_hours", 100.0)
+                    existing.interval_type = mt_data.get("interval_type", "hours")
+                    existing.icon = mt_data.get("icon")
+                    # Don't overwrite is_system
+                    restored["maintenance_types"] += 1
+                else:
+                    skipped["maintenance_types"] += 1
+                    skipped_details["maintenance_types"].append(mt_data["name"])
+            else:
+                mt = MaintenanceType(
+                    name=mt_data["name"],
+                    description=mt_data.get("description"),
+                    default_interval_hours=mt_data.get("default_interval_hours", 100.0),
+                    interval_type=mt_data.get("interval_type", "hours"),
+                    icon=mt_data.get("icon"),
+                    is_system=mt_data.get("is_system", False),
+                )
+                db.add(mt)
+                restored["maintenance_types"] += 1
+
+    # Restore archives (skip duplicates by content_hash - overwrite not supported for archives)
+    if "archives" in backup:
+        for archive_data in backup["archives"]:
+            # Skip if no content_hash or already exists
+            content_hash = archive_data.get("content_hash")
+            if content_hash:
+                result = await db.execute(
+                    select(PrintArchive).where(PrintArchive.content_hash == content_hash)
+                )
+                existing = result.scalar_one_or_none()
+                if existing:
+                    skipped["archives"] += 1
+                    skipped_details["archives"].append(archive_data.get("filename", "Unknown"))
+                    continue
+
+            # Only restore if file exists (from ZIP extraction)
+            file_path = archive_data.get("file_path")
+            if file_path and (base_dir / file_path).exists():
+                archive = PrintArchive(
+                    filename=archive_data["filename"],
+                    file_path=file_path,
+                    file_size=archive_data.get("file_size", 0),
+                    content_hash=content_hash,
+                    thumbnail_path=archive_data.get("thumbnail_path"),
+                    timelapse_path=archive_data.get("timelapse_path"),
+                    source_3mf_path=archive_data.get("source_3mf_path"),
+                    print_name=archive_data.get("print_name"),
+                    print_time_seconds=archive_data.get("print_time_seconds"),
+                    filament_used_grams=archive_data.get("filament_used_grams"),
+                    filament_type=archive_data.get("filament_type"),
+                    filament_color=archive_data.get("filament_color"),
+                    layer_height=archive_data.get("layer_height"),
+                    total_layers=archive_data.get("total_layers"),
+                    nozzle_diameter=archive_data.get("nozzle_diameter"),
+                    bed_temperature=archive_data.get("bed_temperature"),
+                    nozzle_temperature=archive_data.get("nozzle_temperature"),
+                    status=archive_data.get("status", "completed"),
+                    makerworld_url=archive_data.get("makerworld_url"),
+                    designer=archive_data.get("designer"),
+                    is_favorite=archive_data.get("is_favorite", False),
+                    tags=archive_data.get("tags"),
+                    notes=archive_data.get("notes"),
+                    cost=archive_data.get("cost"),
+                    failure_reason=archive_data.get("failure_reason"),
+                    energy_kwh=archive_data.get("energy_kwh"),
+                    energy_cost=archive_data.get("energy_cost"),
+                    extra_data=archive_data.get("extra_data"),
+                    photos=archive_data.get("photos"),
+                )
+                db.add(archive)
+                restored["archives"] = restored.get("archives", 0) + 1
+
     await db.commit()
 
+    import logging
+    logger = logging.getLogger(__name__)
+
+    # If printers were in the backup (restored, updated, or skipped), reconnect all active printers
+    # This ensures connections are re-established after restore, even if printers were skipped
+    if "printers" in backup:
+        # Need fresh query after commit to get proper IDs for newly created printers
+        result = await db.execute(
+            select(Printer).where(Printer.is_active == True)
+        )
+        active_printers = result.scalars().all()
+        logger.info(f"Restore: Found {len(active_printers)} active printers to reconnect")
+        for printer in active_printers:
+            logger.info(f"Restore: Reconnecting printer {printer.name} (id={printer.id}, ip={printer.ip_address}, access_code={'SET' if printer.access_code and printer.access_code != 'CHANGE_ME' else 'NOT SET'})")
+            # This will disconnect existing connection (if any) and reconnect
+            try:
+                connected = await printer_manager.connect_printer(printer)
+                logger.info(f"Restore: Printer {printer.name} connection result: {connected}")
+            except Exception as e:
+                logger.error(f"Restore: Failed to connect printer {printer.name}: {e}")
+
+    # If settings were restored, check if Spoolman needs to be reconnected
+    if "settings" in backup:
+        spoolman_enabled = await get_setting(db, "spoolman_enabled")
+        spoolman_url = await get_setting(db, "spoolman_url")
+        if spoolman_enabled and spoolman_enabled.lower() == "true" and spoolman_url:
+            try:
+                client = await init_spoolman_client(spoolman_url)
+                if await client.health_check():
+                    pass  # Connected successfully
+            except Exception:
+                pass  # Spoolman connection failed, but don't fail the restore
+
+    # Build summary message
+    restored_parts = []
+    for key, count in restored.items():
+        if count > 0:
+            restored_parts.append(f"{count} {key.replace('_', ' ')}")
+
+    if files_restored > 0:
+        restored_parts.append(f"{files_restored} files")
+
+    skipped_parts = []
+    total_skipped = sum(skipped.values())
+    for key, count in skipped.items():
+        if count > 0:
+            skipped_parts.append(f"{count} {key.replace('_', ' ')}")
+
+    message_parts = []
+    if restored_parts:
+        message_parts.append(f"Restored: {', '.join(restored_parts)}")
+    if skipped_parts:
+        message_parts.append(f"Skipped (already exist): {', '.join(skipped_parts)}")
+
     return {
         "success": True,
-        "message": f"Restored {restored['settings']} settings, {restored['notification_providers']} notification providers, {restored['smart_plugs']} smart plugs",
+        "message": ". ".join(message_parts) if message_parts else "Nothing to restore",
         "restored": restored,
+        "skipped": skipped,
+        "skipped_details": skipped_details,
+        "files_restored": files_restored,
+        "total_skipped": total_skipped,
     }

+ 110 - 20
backend/app/api/routes/updates.py

@@ -2,7 +2,8 @@
 
 import asyncio
 import logging
-import subprocess
+import os
+import shutil
 import sys
 from pathlib import Path
 
@@ -27,6 +28,30 @@ _update_status = {
 }
 
 
+def _find_executable(name: str) -> str | None:
+    """Find an executable in PATH or common locations."""
+    # Try standard PATH first
+    path = shutil.which(name)
+    if path:
+        return path
+
+    # Common locations for executables (useful when running as systemd service)
+    common_paths = [
+        f"/usr/bin/{name}",
+        f"/usr/local/bin/{name}",
+        f"/opt/homebrew/bin/{name}",
+        f"/home/linuxbrew/.linuxbrew/bin/{name}",
+        f"{os.path.expanduser('~')}/.nvm/current/bin/{name}",
+        f"{os.path.expanduser('~')}/.local/bin/{name}",
+    ]
+
+    for p in common_paths:
+        if os.path.isfile(p) and os.access(p, os.X_OK):
+            return p
+
+    return None
+
+
 def parse_version(version: str) -> tuple[int, ...]:
     """Parse version string into tuple for comparison."""
     # Remove 'v' prefix if present
@@ -140,21 +165,55 @@ async def check_for_updates(db: AsyncSession = Depends(get_db)):
 
 
 async def _perform_update():
-    """Perform the actual update using git pull."""
+    """Perform the actual update using git fetch and reset."""
     global _update_status
 
     try:
+        base_dir = settings.base_dir
+
+        # Find git executable (may not be in PATH when running as systemd service)
+        git_path = _find_executable("git")
+        if not git_path:
+            _update_status = {
+                "status": "error",
+                "progress": 0,
+                "message": "Git not found",
+                "error": "Could not find git executable. Please ensure git is installed.",
+            }
+            return
+
+        logger.info(f"Using git at: {git_path}")
+
+        # Git config to avoid safe.directory issues
+        git_config = ["-c", f"safe.directory={base_dir}"]
+
+        _update_status = {
+            "status": "downloading",
+            "progress": 10,
+            "message": "Configuring git...",
+            "error": None,
+        }
+
+        # Ensure remote uses HTTPS (SSH may not be available)
+        https_url = f"https://github.com/{GITHUB_REPO}.git"
+        process = await asyncio.create_subprocess_exec(
+            git_path, *git_config, "remote", "set-url", "origin", https_url,
+            cwd=str(base_dir),
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE,
+        )
+        await process.communicate()
+
         _update_status = {
             "status": "downloading",
             "progress": 20,
-            "message": "Pulling latest changes...",
+            "message": "Fetching latest changes...",
             "error": None,
         }
 
-        # Run git pull in the project directory
-        base_dir = settings.base_dir
+        # Fetch from origin
         process = await asyncio.create_subprocess_exec(
-            "git", "pull", "--rebase",
+            git_path, *git_config, "fetch", "origin", "main",
             cwd=str(base_dir),
             stdout=asyncio.subprocess.PIPE,
             stderr=asyncio.subprocess.PIPE,
@@ -162,12 +221,39 @@ async def _perform_update():
         stdout, stderr = await process.communicate()
 
         if process.returncode != 0:
-            error_msg = stderr.decode() if stderr else "Git pull failed"
-            logger.error(f"Git pull failed: {error_msg}")
+            error_msg = stderr.decode() if stderr else "Git fetch failed"
+            logger.error(f"Git fetch failed: {error_msg}")
             _update_status = {
                 "status": "error",
                 "progress": 0,
-                "message": "Failed to pull updates",
+                "message": "Failed to fetch updates",
+                "error": error_msg,
+            }
+            return
+
+        _update_status = {
+            "status": "downloading",
+            "progress": 40,
+            "message": "Applying updates...",
+            "error": None,
+        }
+
+        # Hard reset to origin/main (clean update, no merge conflicts)
+        process = await asyncio.create_subprocess_exec(
+            git_path, *git_config, "reset", "--hard", "origin/main",
+            cwd=str(base_dir),
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE,
+        )
+        stdout, stderr = await process.communicate()
+
+        if process.returncode != 0:
+            error_msg = stderr.decode() if stderr else "Git reset failed"
+            logger.error(f"Git reset failed: {error_msg}")
+            _update_status = {
+                "status": "error",
+                "progress": 0,
+                "message": "Failed to apply updates",
                 "error": error_msg,
             }
             return
@@ -191,19 +277,21 @@ async def _perform_update():
         if process.returncode != 0:
             logger.warning(f"pip install warning: {stderr.decode() if stderr else 'unknown'}")
 
-        _update_status = {
-            "status": "installing",
-            "progress": 70,
-            "message": "Building frontend...",
-            "error": None,
-        }
-
-        # Build frontend
+        # Try to build frontend if npm is available (optional - static files are pre-built)
+        npm_path = _find_executable("npm")
         frontend_dir = base_dir / "frontend"
-        if frontend_dir.exists():
+
+        if npm_path and frontend_dir.exists():
+            _update_status = {
+                "status": "installing",
+                "progress": 70,
+                "message": "Building frontend...",
+                "error": None,
+            }
+
             # npm install
             process = await asyncio.create_subprocess_exec(
-                "npm", "install",
+                npm_path, "install",
                 cwd=str(frontend_dir),
                 stdout=asyncio.subprocess.PIPE,
                 stderr=asyncio.subprocess.PIPE,
@@ -212,7 +300,7 @@ async def _perform_update():
 
             # npm run build
             process = await asyncio.create_subprocess_exec(
-                "npm", "run", "build",
+                npm_path, "run", "build",
                 cwd=str(frontend_dir),
                 stdout=asyncio.subprocess.PIPE,
                 stderr=asyncio.subprocess.PIPE,
@@ -221,6 +309,8 @@ async def _perform_update():
 
             if process.returncode != 0:
                 logger.warning(f"Frontend build warning: {stderr.decode() if stderr else 'unknown'}")
+        else:
+            logger.info("npm not found or frontend dir missing - using pre-built static files")
 
         _update_status = {
             "status": "complete",

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

@@ -1,21 +1,49 @@
 from pathlib import Path
 from pydantic_settings import BaseSettings
+import logging
 
 # Application version - single source of truth
 APP_VERSION = "0.1.5b"
-GITHUB_REPO = "maziggy/bambusy"
+GITHUB_REPO = "maziggy/bambuddy"
+
+# Base directory for path calculations
+_base_dir = Path(__file__).resolve().parent.parent.parent.parent
+
+
+def _migrate_database() -> Path:
+    """Migrate database from old name to new name if needed."""
+    old_db = _base_dir / "bambutrack.db"
+    new_db = _base_dir / "bambuddy.db"
+
+    # If old database exists and new one doesn't, rename it
+    if old_db.exists() and not new_db.exists():
+        try:
+            old_db.rename(new_db)
+            logging.info(f"Migrated database: {old_db} -> {new_db}")
+        except Exception as e:
+            logging.warning(f"Could not migrate database: {e}. Using old location.")
+            return old_db
+
+    # If old database exists (and new one now exists too), it was migrated
+    # If only new exists, use new
+    # If neither exists, use new (will be created)
+    return new_db if new_db.exists() or not old_db.exists() else old_db
+
+
+# Determine database path (handles migration)
+_db_path = _migrate_database()
 
 
 class Settings(BaseSettings):
-    app_name: str = "BambuTrack"
+    app_name: str = "Bambuddy"
     debug: bool = False  # Default to production mode
 
     # Paths
-    base_dir: Path = Path(__file__).resolve().parent.parent.parent.parent
+    base_dir: Path = _base_dir
     archive_dir: Path = base_dir / "archive"
     static_dir: Path = base_dir / "static"
     log_dir: Path = base_dir / "logs"
-    database_url: str = f"sqlite+aiosqlite:///{base_dir}/bambutrack.db"
+    database_url: str = f"sqlite+aiosqlite:///{_db_path}"
 
     # Logging
     log_level: str = "INFO"  # Override with LOG_LEVEL env var or DEBUG=true

+ 4 - 4
backend/app/i18n/__init__.py

@@ -33,8 +33,8 @@ EN = {
         "soon": "Soon",
 
         # Test notification
-        "test_title": "BambuTrack Test",
-        "test_message": "This is a test notification from BambuTrack. If you see this, notifications are working correctly!",
+        "test_title": "Bambuddy Test",
+        "test_message": "This is a test notification from Bambuddy. If you see this, notifications are working correctly!",
     }
 }
 
@@ -69,8 +69,8 @@ DE = {
         "soon": "Bald",
 
         # Test notification
-        "test_title": "BambuTrack Test",
-        "test_message": "Dies ist eine Testbenachrichtigung von BambuTrack. Wenn Sie dies sehen, funktionieren die Benachrichtigungen!",
+        "test_title": "Bambuddy Test",
+        "test_message": "Dies ist eine Testbenachrichtigung von Bambuddy. Wenn Sie dies sehen, funktionieren die Benachrichtigungen!",
     }
 }
 

+ 3 - 3
backend/app/main.py

@@ -29,7 +29,7 @@ root_logger.addHandler(console_handler)
 
 # File handler - only in production or if explicitly enabled
 if app_settings.log_to_file:
-    log_file = app_settings.log_dir / "bambutrack.log"
+    log_file = app_settings.log_dir / "bambuddy.log"
     file_handler = RotatingFileHandler(
         log_file,
         maxBytes=5*1024*1024,  # 5MB
@@ -47,7 +47,7 @@ if not app_settings.debug:
     logging.getLogger("httpcore").setLevel(logging.WARNING)
     logging.getLogger("httpx").setLevel(logging.WARNING)
 
-logging.info(f"BambuTrack starting - debug={app_settings.debug}, log_level={log_level_str}")
+logging.info(f"Bambuddy starting - debug={app_settings.debug}, log_level={log_level_str}")
 from fastapi.staticfiles import StaticFiles
 from fastapi.responses import FileResponse
 
@@ -1062,7 +1062,7 @@ async def serve_frontend():
     if index_file.exists():
         return FileResponse(index_file)
     return {
-        "message": "BambuTrack API",
+        "message": "Bambuddy API",
         "docs": "/docs",
         "frontend": "Build and place React app in /static directory",
     }

+ 1 - 1
backend/app/models/notification_template.py

@@ -84,7 +84,7 @@ DEFAULT_TEMPLATES = [
     {
         "event_type": "test",
         "name": "Test Notification",
-        "title_template": "BambuTrack Test",
+        "title_template": "Bambuddy Test",
         "body_template": "This is a test notification. If you see this, notifications are working!",
     },
 ]

+ 10 - 10
backend/app/schemas/notification_template.py

@@ -42,7 +42,7 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "filename": "Benchy.3mf",
         "estimated_time": "1h 23m",
         "timestamp": "2024-01-15 14:30",
-        "app_name": "BambuTrack",
+        "app_name": "Bambuddy",
     },
     "print_complete": {
         "printer": "Bambu X1C",
@@ -50,7 +50,7 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "duration": "1h 18m",
         "filament_grams": "15.2",
         "timestamp": "2024-01-15 15:48",
-        "app_name": "BambuTrack",
+        "app_name": "Bambuddy",
     },
     "print_failed": {
         "printer": "Bambu X1C",
@@ -58,14 +58,14 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "duration": "0h 45m",
         "reason": "Filament runout",
         "timestamp": "2024-01-15 15:15",
-        "app_name": "BambuTrack",
+        "app_name": "Bambuddy",
     },
     "print_stopped": {
         "printer": "Bambu X1C",
         "filename": "Benchy.3mf",
         "duration": "0h 30m",
         "timestamp": "2024-01-15 15:00",
-        "app_name": "BambuTrack",
+        "app_name": "Bambuddy",
     },
     "print_progress": {
         "printer": "Bambu X1C",
@@ -73,19 +73,19 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "progress": "50",
         "remaining_time": "0h 41m",
         "timestamp": "2024-01-15 15:00",
-        "app_name": "BambuTrack",
+        "app_name": "Bambuddy",
     },
     "printer_offline": {
         "printer": "Bambu X1C",
         "timestamp": "2024-01-15 14:30",
-        "app_name": "BambuTrack",
+        "app_name": "Bambuddy",
     },
     "printer_error": {
         "printer": "Bambu X1C",
         "error_type": "AMS Error",
         "error_detail": "Filament slot 1 jammed",
         "timestamp": "2024-01-15 14:30",
-        "app_name": "BambuTrack",
+        "app_name": "Bambuddy",
     },
     "filament_low": {
         "printer": "Bambu X1C",
@@ -93,16 +93,16 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "remaining_percent": "15",
         "color": "Black PLA",
         "timestamp": "2024-01-15 14:30",
-        "app_name": "BambuTrack",
+        "app_name": "Bambuddy",
     },
     "maintenance_due": {
         "printer": "Bambu X1C",
         "items": "• Nozzle cleaning (OVERDUE)\n• Carbon rod lubrication (Soon)",
         "timestamp": "2024-01-15 14:30",
-        "app_name": "BambuTrack",
+        "app_name": "Bambuddy",
     },
     "test": {
-        "app_name": "BambuTrack",
+        "app_name": "Bambuddy",
         "timestamp": "2024-01-15 14:30",
     },
 }

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

@@ -50,7 +50,7 @@ class BambuCloudService:
         """Get headers for authenticated requests."""
         headers = {
             "Content-Type": "application/json",
-            "User-Agent": "BambuTrack/1.0",
+            "User-Agent": "Bambuddy/1.0",
         }
         if self.access_token:
             headers["Authorization"] = f"Bearer {self.access_token}"

+ 22 - 1
backend/app/services/bambu_mqtt.py

@@ -278,6 +278,27 @@ class BambuMQTTClient:
     def topic_publish(self) -> str:
         return f"device/{self.serial_number}/request"
 
+    # Maximum time (seconds) without a message before considering connection stale
+    STALE_TIMEOUT = 60.0
+
+    def is_stale(self) -> bool:
+        """Check if the connection is stale (no messages for too long)."""
+        if self._last_message_time == 0:
+            return False  # Never received a message yet
+        time_since_last = time.time() - self._last_message_time
+        return time_since_last > self.STALE_TIMEOUT
+
+    def check_staleness(self) -> bool:
+        """Check staleness and update connected state if stale. Returns True if connected."""
+        if self.state.connected and self.is_stale():
+            logger.warning(
+                f"[{self.serial_number}] Connection stale - no message for {time.time() - self._last_message_time:.1f}s"
+            )
+            self.state.connected = False
+            if self.on_state_change:
+                self.on_state_change(self.state)
+        return self.state.connected
+
     def _on_connect(self, client, userdata, flags, rc, properties=None):
         if rc == 0:
             self.state.connected = True
@@ -1481,7 +1502,7 @@ class BambuMQTTClient:
         self._loop = loop
         self._client = mqtt.Client(
             callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
-            client_id=f"bambutrack_{self.serial_number}",
+            client_id=f"bambuddy_{self.serial_number}",
             protocol=mqtt.MQTTv311,
         )
 

+ 4 - 4
backend/app/services/notification_service.py

@@ -119,7 +119,7 @@ class NotificationService:
         """Build notification title and body from template."""
         # Add common variables
         variables["timestamp"] = datetime.now().strftime("%Y-%m-%d %H:%M")
-        variables["app_name"] = "BambuTrack"
+        variables["app_name"] = "Bambuddy"
 
         template = await self._get_template(db, event_type)
         if not template:
@@ -139,7 +139,7 @@ class NotificationService:
         if db:
             title, message = await self._build_message_from_template(db, "test", {})
         else:
-            title = "BambuTrack Test"
+            title = "Bambuddy Test"
             message = "This is a test notification. If you see this, notifications are working!"
 
         try:
@@ -287,7 +287,7 @@ class NotificationService:
             msg = MIMEMultipart()
             msg["From"] = from_email
             msg["To"] = to_email
-            msg["Subject"] = f"[BambuTrack] {subject}"
+            msg["Subject"] = f"[Bambuddy] {subject}"
             msg.attach(MIMEText(body, "plain"))
 
             if security == "ssl":
@@ -357,7 +357,7 @@ class NotificationService:
             custom_field_title: title,
             custom_field_message: message,
             "timestamp": datetime.now().isoformat(),
-            "source": "BambuTrack",
+            "source": "Bambuddy",
         }
 
         headers = {"Content-Type": "application/json"}

+ 16 - 9
backend/app/services/printer_manager.py

@@ -106,22 +106,29 @@ class PrinterManager:
             self.disconnect_printer(printer_id)
 
     def get_status(self, printer_id: int) -> PrinterState | None:
-        """Get the current status of a printer."""
+        """Get the current status of a printer (checks for stale connections)."""
         if printer_id in self._clients:
-            return self._clients[printer_id].state
+            client = self._clients[printer_id]
+            # Check staleness and update connected state if needed
+            client.check_staleness()
+            return client.state
         return None
 
     def get_all_statuses(self) -> dict[int, PrinterState]:
-        """Get status of all connected printers."""
-        return {
-            printer_id: client.state
-            for printer_id, client in self._clients.items()
-        }
+        """Get status of all connected printers (checks for stale connections)."""
+        result = {}
+        for printer_id, client in self._clients.items():
+            # Check staleness and update connected state if needed
+            client.check_staleness()
+            result[printer_id] = client.state
+        return result
 
     def is_connected(self, printer_id: int) -> bool:
-        """Check if a printer is connected."""
+        """Check if a printer is connected (checks for stale connections)."""
         if printer_id in self._clients:
-            return self._clients[printer_id].state.connected
+            client = self._clients[printer_id]
+            # Check staleness and update connected state if needed
+            return client.check_staleness()
         return False
 
     def get_client(self, printer_id: int) -> BambuMQTTClient | None:

+ 1 - 1
frontend/index.html

@@ -3,7 +3,7 @@
   <head>
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>Bambusy</title>
+    <title>Bambuddy</title>
     <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" />

BIN
frontend/public/img/android-chrome-192x192.png


BIN
frontend/public/img/android-chrome-512x512.png


BIN
frontend/public/img/apple-touch-icon.png


BIN
frontend/public/img/bambuddy_logo_dark.png


BIN
frontend/public/img/bambuddy_logo_light.png


BIN
frontend/public/img/bambusy_logo_dark.png


BIN
frontend/public/img/bambusy_logo_light.png


BIN
frontend/public/img/favicon-16x16.png


BIN
frontend/public/img/favicon-32x32.png


BIN
frontend/public/img/favicon.png


+ 34 - 6
frontend/src/api/client.ts

@@ -1149,21 +1149,49 @@ export const api = {
     }),
   resetSettings: () =>
     request<AppSettings>('/settings/reset', { method: 'POST' }),
-  exportBackup: async () => {
-    const response = await fetch(`${API_BASE}/settings/backup`);
-    return response.json();
+  exportBackup: async (categories?: Record<string, boolean>): Promise<{ blob: Blob; filename: string }> => {
+    const params = new URLSearchParams();
+    if (categories) {
+      if (categories.settings !== undefined) params.set('include_settings', String(categories.settings));
+      if (categories.notifications !== undefined) params.set('include_notifications', String(categories.notifications));
+      if (categories.templates !== undefined) params.set('include_templates', String(categories.templates));
+      if (categories.smart_plugs !== undefined) params.set('include_smart_plugs', String(categories.smart_plugs));
+      if (categories.printers !== undefined) params.set('include_printers', String(categories.printers));
+      if (categories.filaments !== undefined) params.set('include_filaments', String(categories.filaments));
+      if (categories.maintenance !== undefined) params.set('include_maintenance', String(categories.maintenance));
+      if (categories.archives !== undefined) params.set('include_archives', String(categories.archives));
+      if (categories.access_codes !== undefined) params.set('include_access_codes', String(categories.access_codes));
+    }
+    const url = `${API_BASE}/settings/backup${params.toString() ? '?' + params.toString() : ''}`;
+    const response = await fetch(url);
+
+    // Get filename from Content-Disposition header
+    const contentDisposition = response.headers.get('Content-Disposition');
+    let filename = 'bambuddy-backup.json';
+    if (contentDisposition) {
+      const match = contentDisposition.match(/filename=([^;]+)/);
+      if (match) filename = match[1].trim();
+    }
+
+    const blob = await response.blob();
+    return { blob, filename };
   },
-  importBackup: async (file: File) => {
+  importBackup: async (file: File, overwrite = false) => {
     const formData = new FormData();
     formData.append('file', file);
-    const response = await fetch(`${API_BASE}/settings/restore`, {
+    const url = `${API_BASE}/settings/restore${overwrite ? '?overwrite=true' : ''}`;
+    const response = await fetch(url, {
       method: 'POST',
       body: formData,
     });
     return response.json() as Promise<{
       success: boolean;
       message: string;
-      restored?: { settings: number; notification_providers: number; smart_plugs: number };
+      restored?: Record<string, number>;
+      skipped?: Record<string, number>;
+      skipped_details?: Record<string, string[]>;
+      files_restored?: number;
+      total_skipped?: number;
     }>;
   },
   checkFfmpeg: () =>

+ 1 - 1
frontend/src/components/AddNotificationModal.tsx

@@ -167,7 +167,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
       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: 'topic', label: 'Topic', placeholder: 'my-bambuddy', type: 'text', required: true },
           { key: 'auth_token', label: 'Auth Token', placeholder: 'Optional authentication', type: 'password', required: false },
         ];
       case 'pushover':

+ 295 - 0
frontend/src/components/BackupModal.tsx

@@ -0,0 +1,295 @@
+import { useEffect, useState } from 'react';
+import { Download, X, Settings, Bell, FileText, Plug, Printer, Palette, Wrench, Archive, Loader2, Key, AlertTriangle } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { Card, CardContent } from './Card';
+import { Button } from './Button';
+import { Toggle } from './Toggle';
+
+interface BackupCategory {
+  id: string;
+  labelKey: string;
+  defaultLabel: string;
+  icon: React.ReactNode;
+  default: boolean;
+  description: string;
+}
+
+const BACKUP_CATEGORIES: BackupCategory[] = [
+  {
+    id: 'settings',
+    labelKey: 'backup.categories.settings',
+    defaultLabel: 'App Settings',
+    icon: <Settings className="w-4 h-4" />,
+    default: true,
+    description: 'Language, theme, update preferences',
+  },
+  {
+    id: 'notifications',
+    labelKey: 'backup.categories.notifications',
+    defaultLabel: 'Notification Providers',
+    icon: <Bell className="w-4 h-4" />,
+    default: true,
+    description: 'ntfy, Pushover, Discord, etc.',
+  },
+  {
+    id: 'templates',
+    labelKey: 'backup.categories.templates',
+    defaultLabel: 'Notification Templates',
+    icon: <FileText className="w-4 h-4" />,
+    default: true,
+    description: 'Custom message templates',
+  },
+  {
+    id: 'smart_plugs',
+    labelKey: 'backup.categories.smartPlugs',
+    defaultLabel: 'Smart Plugs',
+    icon: <Plug className="w-4 h-4" />,
+    default: true,
+    description: 'Tasmota plug configurations',
+  },
+  {
+    id: 'printers',
+    labelKey: 'backup.categories.printers',
+    defaultLabel: 'Printers',
+    icon: <Printer className="w-4 h-4" />,
+    default: false,
+    description: 'Printer info (access codes excluded)',
+  },
+  {
+    id: 'filaments',
+    labelKey: 'backup.categories.filaments',
+    defaultLabel: 'Filament Inventory',
+    icon: <Palette className="w-4 h-4" />,
+    default: false,
+    description: 'Filament types and costs',
+  },
+  {
+    id: 'maintenance',
+    labelKey: 'backup.categories.maintenance',
+    defaultLabel: 'Maintenance Types',
+    icon: <Wrench className="w-4 h-4" />,
+    default: false,
+    description: 'Custom maintenance schedules',
+  },
+  {
+    id: 'archives',
+    labelKey: 'backup.categories.archives',
+    defaultLabel: 'Print Archives',
+    icon: <Archive className="w-4 h-4" />,
+    default: false,
+    description: 'All print data + files (3MF, thumbnails, photos)',
+  },
+];
+
+interface BackupModalProps {
+  onClose: () => void;
+  onExport: (categories: Record<string, boolean>) => Promise<void>;
+}
+
+export function BackupModal({ onClose, onExport }: BackupModalProps) {
+  const { t } = useTranslation();
+  const [selected, setSelected] = useState<Record<string, boolean>>(() => {
+    const initial: Record<string, boolean> = {};
+    BACKUP_CATEGORIES.forEach((cat) => {
+      initial[cat.id] = cat.default;
+    });
+    return initial;
+  });
+  const [includeAccessCodes, setIncludeAccessCodes] = useState(false);
+  const [isExporting, setIsExporting] = useState(false);
+
+  // Close on Escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  const toggleCategory = (id: string) => {
+    setSelected((prev) => ({ ...prev, [id]: !prev[id] }));
+  };
+
+  const selectAll = () => {
+    const all: Record<string, boolean> = {};
+    BACKUP_CATEGORIES.forEach((cat) => {
+      all[cat.id] = true;
+    });
+    setSelected(all);
+  };
+
+  const selectNone = () => {
+    const none: Record<string, boolean> = {};
+    BACKUP_CATEGORIES.forEach((cat) => {
+      none[cat.id] = false;
+    });
+    setSelected(none);
+  };
+
+  const selectedCount = Object.values(selected).filter(Boolean).length;
+
+  const handleExport = async () => {
+    setIsExporting(true);
+    try {
+      await onExport({ ...selected, access_codes: includeAccessCodes && selected.printers });
+    } finally {
+      setIsExporting(false);
+    }
+  };
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
+      onClick={isExporting ? undefined : onClose}
+    >
+      <Card className="w-full max-w-lg" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
+        <CardContent className="p-0">
+          {/* Header */}
+          <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
+            <div className="flex items-center gap-3">
+              <div className="p-2 rounded-full bg-bambu-green/20 text-bambu-green">
+                <Download className="w-5 h-5" />
+              </div>
+              <div>
+                <h3 className="text-lg font-semibold text-white">
+                  {t('backup.exportTitle', { defaultValue: 'Export Backup' })}
+                </h3>
+                <p className="text-sm text-bambu-gray">
+                  {t('backup.selectCategories', { defaultValue: 'Select data to include' })}
+                </p>
+              </div>
+            </div>
+            <button
+              onClick={onClose}
+              className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
+            >
+              <X className="w-5 h-5" />
+            </button>
+          </div>
+
+          {/* Quick actions */}
+          <div className="flex gap-2 px-4 pt-4">
+            <button
+              onClick={selectAll}
+              disabled={isExporting}
+              className="text-sm text-bambu-green hover:text-bambu-green/80 disabled:opacity-50 disabled:cursor-not-allowed"
+            >
+              {t('common.selectAll', { defaultValue: 'Select All' })}
+            </button>
+            <span className="text-bambu-gray">|</span>
+            <button
+              onClick={selectNone}
+              disabled={isExporting}
+              className="text-sm text-bambu-gray hover:text-white disabled:opacity-50 disabled:cursor-not-allowed"
+            >
+              {t('common.selectNone', { defaultValue: 'Select None' })}
+            </button>
+          </div>
+
+          {/* Categories */}
+          <div className={`p-4 space-y-2 max-h-[400px] overflow-y-auto ${isExporting ? 'opacity-50 pointer-events-none' : ''}`}>
+            {BACKUP_CATEGORIES.map((category) => (
+              <label
+                key={category.id}
+                className={`flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-colors ${
+                  selected[category.id]
+                    ? 'bg-bambu-green/10 border border-bambu-green/30'
+                    : 'bg-bambu-dark hover:bg-bambu-dark-tertiary border border-transparent'
+                }`}
+              >
+                <input
+                  type="checkbox"
+                  checked={selected[category.id]}
+                  onChange={() => toggleCategory(category.id)}
+                  disabled={isExporting}
+                  className="w-4 h-4 rounded border-bambu-gray bg-bambu-dark text-bambu-green focus:ring-bambu-green focus:ring-offset-0"
+                />
+                <div className={`${selected[category.id] ? 'text-bambu-green' : 'text-bambu-gray'}`}>
+                  {category.icon}
+                </div>
+                <div className="flex-1">
+                  <div className="text-white text-sm font-medium">
+                    {t(category.labelKey, { defaultValue: category.defaultLabel })}
+                  </div>
+                  <div className="text-xs text-bambu-gray">{category.description}</div>
+                </div>
+              </label>
+            ))}
+          </div>
+
+          {/* Archive warning */}
+          {selected.archives && (
+            <div className="mx-4 mb-2 p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
+              <div className="flex items-start gap-2 text-sm">
+                <Archive className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
+                <div className="text-yellow-200 dark:text-yellow-200 text-yellow-700">
+                  <span className="font-medium">ZIP file will be created.</span>
+                  <span className="text-yellow-600 dark:text-yellow-200/70"> Includes all 3MF files, thumbnails, timelapses, and photos. This may take a while and result in a large file.</span>
+                </div>
+              </div>
+            </div>
+          )}
+
+          {/* Access codes option - only shown when printers are selected */}
+          {selected.printers && (
+            <div className="mx-4 mb-2 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary">
+              <div className="flex items-center justify-between">
+                <div className="flex items-start gap-2">
+                  <Key className="w-4 h-4 text-orange-500 dark:text-orange-400 mt-0.5 flex-shrink-0" />
+                  <div>
+                    <p className="text-sm font-medium text-white">Include Access Codes</p>
+                    <p className="text-xs text-bambu-gray">For transferring to another machine</p>
+                  </div>
+                </div>
+                <Toggle checked={includeAccessCodes} onChange={setIncludeAccessCodes} />
+              </div>
+              {includeAccessCodes && (
+                <div className="mt-2 p-2 rounded bg-orange-500/10 border border-orange-500/30">
+                  <div className="flex items-start gap-2 text-xs">
+                    <AlertTriangle className="w-3 h-3 text-orange-500 dark:text-orange-400 mt-0.5 flex-shrink-0" />
+                    <span className="text-orange-700 dark:text-orange-200">
+                      Access codes will be included in plain text. Keep this backup file secure!
+                    </span>
+                  </div>
+                </div>
+              )}
+            </div>
+          )}
+
+          {/* Footer */}
+          <div className="flex items-center justify-between p-4 border-t border-bambu-dark-tertiary">
+            <span className="text-sm text-bambu-gray">
+              {t('backup.selectedCount', {
+                count: selectedCount,
+                defaultValue: `${selectedCount} categories selected`,
+              })}
+            </span>
+            <div className="flex gap-3">
+              <Button variant="secondary" onClick={onClose} disabled={isExporting}>
+                {t('common.cancel', { defaultValue: 'Cancel' })}
+              </Button>
+              <Button
+                onClick={handleExport}
+                disabled={selectedCount === 0 || isExporting}
+                className="bg-bambu-green hover:bg-bambu-green-dark disabled:opacity-50 disabled:cursor-not-allowed min-w-[100px]"
+              >
+                {isExporting ? (
+                  <>
+                    <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+                    {t('backup.exporting', { defaultValue: 'Exporting...' })}
+                  </>
+                ) : (
+                  <>
+                    <Download className="w-4 h-4 mr-2" />
+                    {t('backup.export', { defaultValue: 'Export' })}
+                  </>
+                )}
+              </Button>
+            </div>
+          </div>
+        </CardContent>
+      </Card>
+    </div>
+  );
+}

+ 1 - 1
frontend/src/components/KProfilesView.tsx

@@ -580,7 +580,7 @@ function KProfileModal({
                 className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none resize-none"
               />
               <p className="text-xs text-bambu-gray mt-1">
-                Notes are saved in Bambusy, not on the printer
+                Notes are saved in Bambuddy, not on the printer
               </p>
             </div>
 

+ 47 - 5
frontend/src/components/Layout.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useCallback, useRef } from 'react';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
-import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, type LucideIcon } from 'lucide-react';
+import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, X, type LucideIcon } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@@ -80,6 +80,9 @@ export function Layout() {
   const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
   const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
   const hasRedirected = useRef(false);
+  const [dismissedUpdateVersion, setDismissedUpdateVersion] = useState<string | null>(() =>
+    sessionStorage.getItem('dismissedUpdateVersion')
+  );
 
   // Check for updates
   const { data: versionInfo } = useQuery({
@@ -102,6 +105,18 @@ export function Layout() {
     refetchInterval: 60 * 60 * 1000, // Check every hour
   });
 
+  // Show update banner if update available and not dismissed for this version
+  const showUpdateBanner = updateCheck?.update_available &&
+    updateCheck.latest_version &&
+    updateCheck.latest_version !== dismissedUpdateVersion;
+
+  const dismissUpdateBanner = () => {
+    if (updateCheck?.latest_version) {
+      sessionStorage.setItem('dismissedUpdateVersion', updateCheck.latest_version);
+      setDismissedUpdateVersion(updateCheck.latest_version);
+    }
+  };
+
   // Redirect to default view on initial load
   useEffect(() => {
     if (!hasRedirected.current && location.pathname === '/') {
@@ -200,8 +215,8 @@ export function Layout() {
         {/* Logo */}
         <div className={`border-b border-bambu-dark-tertiary flex items-center justify-center ${sidebarExpanded ? 'p-4' : 'p-2'}`}>
           <img
-            src={theme === 'dark' ? '/img/bambusy_logo_dark.png' : '/img/bambusy_logo_light.png'}
-            alt="Bambusy"
+            src={theme === 'dark' ? '/img/bambuddy_logo_dark.png' : '/img/bambuddy_logo_light.png'}
+            alt="Bambuddy"
             className={sidebarExpanded ? 'h-16 w-auto' : 'h-8 w-8 object-cover object-left'}
           />
         </div>
@@ -280,7 +295,7 @@ export function Layout() {
               </div>
               <div className="flex items-center gap-1">
                 <a
-                  href="https://github.com/maziggy/bambusy"
+                  href="https://github.com/maziggy/bambuddy"
                   target="_blank"
                   rel="noopener noreferrer"
                   className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
@@ -316,7 +331,7 @@ export function Layout() {
                 </button>
               )}
               <a
-                href="https://github.com/maziggy/bambusy"
+                href="https://github.com/maziggy/bambuddy"
                 target="_blank"
                 rel="noopener noreferrer"
                 className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
@@ -345,6 +360,33 @@ export function Layout() {
 
       {/* Main content */}
       <main className={`flex-1 bg-bambu-dark overflow-auto ${sidebarExpanded ? 'ml-64' : 'ml-16'} transition-all duration-300`}>
+        {/* Persistent update banner */}
+        {showUpdateBanner && (
+          <div className="bg-bambu-green/20 border-b border-bambu-green/30 px-4 py-2 flex items-center justify-between">
+            <div className="flex items-center gap-2 text-sm">
+              <ArrowUpCircle className="w-4 h-4 text-bambu-green" />
+              <span>
+                {t('nav.updateAvailableBanner', {
+                  version: updateCheck?.latest_version,
+                  defaultValue: `Version ${updateCheck?.latest_version} is available!`
+                })}
+              </span>
+              <button
+                onClick={() => navigate('/settings')}
+                className="text-bambu-green hover:text-bambu-green/80 font-medium underline"
+              >
+                {t('nav.viewUpdate', { defaultValue: 'View update' })}
+              </button>
+            </div>
+            <button
+              onClick={dismissUpdateBanner}
+              className="p-1 hover:bg-bambu-dark-tertiary rounded transition-colors"
+              title={t('common.dismiss', { defaultValue: 'Dismiss' })}
+            >
+              <X className="w-4 h-4" />
+            </button>
+          </div>
+        )}
         <Outlet />
       </main>
 

+ 370 - 0
frontend/src/components/RestoreModal.tsx

@@ -0,0 +1,370 @@
+import { useState, useRef, useEffect } from 'react';
+import { Upload, X, AlertTriangle, CheckCircle, SkipForward, RefreshCw, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
+import { Card, CardContent } from './Card';
+import { Button } from './Button';
+import { Toggle } from './Toggle';
+
+interface RestoreResult {
+  success: boolean;
+  message: string;
+  restored?: Record<string, number>;
+  skipped?: Record<string, number>;
+  skipped_details?: Record<string, string[]>;
+  files_restored?: number;
+  total_skipped?: number;
+}
+
+interface RestoreModalProps {
+  onClose: () => void;
+  onRestore: (file: File, overwrite: boolean) => Promise<RestoreResult>;
+  onSuccess: () => void;
+}
+
+type ModalState = 'options' | 'restoring' | 'result';
+
+const CATEGORY_LABELS: Record<string, string> = {
+  settings: 'Settings',
+  notification_providers: 'Notification Providers',
+  notification_templates: 'Notification Templates',
+  smart_plugs: 'Smart Plugs',
+  printers: 'Printers',
+  filaments: 'Filaments',
+  maintenance_types: 'Maintenance Types',
+  archives: 'Archives',
+};
+
+export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProps) {
+  const [state, setState] = useState<ModalState>('options');
+  const [overwrite, setOverwrite] = useState(false);
+  const [selectedFile, setSelectedFile] = useState<File | null>(null);
+  const [result, setResult] = useState<RestoreResult | null>(null);
+  const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
+  const fileInputRef = useRef<HTMLInputElement>(null);
+
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape' && state !== 'restoring') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose, state]);
+
+  const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
+    const file = e.target.files?.[0];
+    if (file) {
+      setSelectedFile(file);
+    }
+  };
+
+  const handleRestore = async () => {
+    if (!selectedFile) return;
+
+    setState('restoring');
+    try {
+      const restoreResult = await onRestore(selectedFile, overwrite);
+      setResult(restoreResult);
+      setState('result');
+      if (restoreResult.success) {
+        onSuccess();
+      }
+    } catch {
+      setResult({
+        success: false,
+        message: 'Failed to restore backup. Please check the file format.',
+      });
+      setState('result');
+    }
+  };
+
+  const toggleCategory = (category: string) => {
+    setExpandedCategories(prev => {
+      const next = new Set(prev);
+      if (next.has(category)) {
+        next.delete(category);
+      } else {
+        next.add(category);
+      }
+      return next;
+    });
+  };
+
+  const totalRestored = result?.restored
+    ? Object.values(result.restored).reduce((a, b) => a + b, 0) + (result.files_restored || 0)
+    : 0;
+  const totalSkipped = result?.total_skipped || 0;
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
+      onMouseDown={(e) => {
+        // Only close if clicking directly on the backdrop, not on children
+        if (e.target === e.currentTarget && state !== 'restoring') {
+          onClose();
+        }
+      }}
+    >
+      <Card className="w-full max-w-lg">
+        <CardContent className="p-0">
+          {/* Header */}
+          <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
+            <div className="flex items-center gap-3">
+              <div className={`p-2 rounded-full ${
+                state === 'result' && result?.success
+                  ? 'bg-bambu-green/20 text-bambu-green'
+                  : state === 'result' && !result?.success
+                  ? 'bg-red-500/20 text-red-500'
+                  : 'bg-blue-500/20 text-blue-500'
+              }`}>
+                {state === 'result' && result?.success ? (
+                  <CheckCircle className="w-5 h-5" />
+                ) : state === 'result' && !result?.success ? (
+                  <AlertTriangle className="w-5 h-5" />
+                ) : (
+                  <Upload className="w-5 h-5" />
+                )}
+              </div>
+              <div>
+                <h3 className="text-lg font-semibold text-white">
+                  {state === 'options' && 'Restore Backup'}
+                  {state === 'restoring' && 'Restoring...'}
+                  {state === 'result' && (result?.success ? 'Restore Complete' : 'Restore Failed')}
+                </h3>
+                <p className="text-sm text-bambu-gray">
+                  {state === 'options' && 'Import settings from a backup file'}
+                  {state === 'restoring' && 'Please wait while your data is being restored'}
+                  {state === 'result' && result?.message}
+                </p>
+              </div>
+            </div>
+            {state !== 'restoring' && (
+              <button
+                onClick={onClose}
+                className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
+              >
+                <X className="w-5 h-5" />
+              </button>
+            )}
+          </div>
+
+          {/* Options State */}
+          {state === 'options' && (
+            <>
+              <div className="p-4 space-y-4">
+                {/* File Selection */}
+                <div>
+                  <input
+                    ref={fileInputRef}
+                    type="file"
+                    accept=".json,.zip"
+                    className="hidden"
+                    onChange={handleFileSelect}
+                  />
+                  <button
+                    type="button"
+                    onClick={() => fileInputRef.current?.click()}
+                    className={`w-full p-4 border-2 border-dashed rounded-lg transition-colors ${
+                      selectedFile
+                        ? 'border-bambu-green bg-bambu-green/10'
+                        : 'border-bambu-dark-tertiary hover:border-bambu-gray'
+                    }`}
+                  >
+                    {selectedFile ? (
+                      <div className="flex items-center justify-center gap-2 text-bambu-green">
+                        <CheckCircle className="w-5 h-5" />
+                        <span className="font-medium">{selectedFile.name}</span>
+                      </div>
+                    ) : (
+                      <div className="flex flex-col items-center gap-2 text-bambu-gray">
+                        <Upload className="w-8 h-8" />
+                        <span>Click to select backup file (.json or .zip)</span>
+                      </div>
+                    )}
+                  </button>
+                </div>
+
+                {/* Info Box */}
+                <div className="p-3 rounded-lg bg-blue-500/10 border border-blue-500/30">
+                  <div className="flex items-start gap-2 text-sm">
+                    <AlertTriangle className="w-4 h-4 text-blue-500 dark:text-blue-400 mt-0.5 flex-shrink-0" />
+                    <div className="text-blue-700 dark:text-blue-200">
+                      <p className="font-medium mb-1">How duplicate handling works:</p>
+                      <ul className="text-blue-600 dark:text-blue-200/80 space-y-1 text-xs">
+                        <li><strong>Printers</strong> - matched by serial number</li>
+                        <li><strong>Smart Plugs</strong> - matched by IP address</li>
+                        <li><strong>Notification Providers</strong> - matched by name</li>
+                        <li><strong>Filaments</strong> - matched by name + type + brand</li>
+                        <li><strong>Archives</strong> - matched by content hash (always skipped)</li>
+                        <li><strong>Settings & Templates</strong> - always overwritten</li>
+                      </ul>
+                    </div>
+                  </div>
+                </div>
+
+                {/* Overwrite Toggle */}
+                <div className="p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary">
+                  <div className="flex items-center justify-between">
+                    <div>
+                      <p className="text-white font-medium flex items-center gap-2">
+                        {overwrite ? (
+                          <RefreshCw className="w-4 h-4 text-orange-400" />
+                        ) : (
+                          <SkipForward className="w-4 h-4 text-bambu-gray" />
+                        )}
+                        {overwrite ? 'Replace existing data' : 'Keep existing data'}
+                      </p>
+                      <p className="text-sm text-bambu-gray mt-1">
+                        {overwrite
+                          ? 'Overwrite items that already exist with backup data'
+                          : 'Only restore items that don\'t already exist'}
+                      </p>
+                    </div>
+                    <Toggle checked={overwrite} onChange={setOverwrite} />
+                  </div>
+                </div>
+
+                {overwrite && (
+                  <div className="p-3 rounded-lg bg-orange-500/10 border border-orange-500/30">
+                    <div className="flex items-start gap-2 text-sm">
+                      <AlertTriangle className="w-4 h-4 text-orange-500 dark:text-orange-400 mt-0.5 flex-shrink-0" />
+                      <div className="text-orange-700 dark:text-orange-200">
+                        <span className="font-medium">Caution:</span> Overwriting will replace your current configurations with data from the backup. Printer access codes are never overwritten for security.
+                      </div>
+                    </div>
+                  </div>
+                )}
+              </div>
+
+              {/* Footer */}
+              <div className="flex items-center justify-end gap-3 p-4 border-t border-bambu-dark-tertiary">
+                <Button type="button" variant="secondary" onClick={onClose}>
+                  Cancel
+                </Button>
+                <Button
+                  type="button"
+                  onClick={handleRestore}
+                  disabled={!selectedFile}
+                  className="bg-bambu-green hover:bg-bambu-green-dark disabled:opacity-50"
+                >
+                  <Upload className="w-4 h-4 mr-2" />
+                  Restore
+                </Button>
+              </div>
+            </>
+          )}
+
+          {/* Restoring State */}
+          {state === 'restoring' && (
+            <div className="p-8 flex flex-col items-center gap-4">
+              <Loader2 className="w-12 h-12 text-bambu-green animate-spin" />
+              <p className="text-bambu-gray">Processing backup file...</p>
+            </div>
+          )}
+
+          {/* Result State */}
+          {state === 'result' && result && (
+            <>
+              <div className="p-4 space-y-4 max-h-[400px] overflow-y-auto">
+                {/* Summary */}
+                <div className="grid grid-cols-2 gap-3">
+                  <div className="p-3 rounded-lg bg-bambu-green/10 border border-bambu-green/30">
+                    <div className="text-2xl font-bold text-bambu-green">{totalRestored}</div>
+                    <div className="text-sm text-bambu-gray">Items Restored</div>
+                  </div>
+                  <div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
+                    <div className="text-2xl font-bold text-yellow-500">{totalSkipped}</div>
+                    <div className="text-sm text-bambu-gray">Items Skipped</div>
+                  </div>
+                </div>
+
+                {/* Restored Details */}
+                {result.restored && Object.entries(result.restored).some(([, count]) => count > 0) && (
+                  <div className="space-y-2">
+                    <h4 className="text-sm font-medium text-bambu-gray flex items-center gap-2">
+                      <CheckCircle className="w-4 h-4 text-bambu-green" />
+                      Restored
+                    </h4>
+                    <div className="space-y-1">
+                      {Object.entries(result.restored)
+                        .filter(([, count]) => count > 0)
+                        .map(([key, count]) => (
+                          <div key={key} className="flex items-center justify-between text-sm p-2 rounded bg-bambu-dark">
+                            <span className="text-white">{CATEGORY_LABELS[key] || key}</span>
+                            <span className="text-bambu-green font-medium">{count}</span>
+                          </div>
+                        ))}
+                      {(result.files_restored || 0) > 0 && (
+                        <div className="flex items-center justify-between text-sm p-2 rounded bg-bambu-dark">
+                          <span className="text-white">Files (3MF, thumbnails, etc.)</span>
+                          <span className="text-bambu-green font-medium">{result.files_restored}</span>
+                        </div>
+                      )}
+                    </div>
+                  </div>
+                )}
+
+                {/* Skipped Details */}
+                {result.skipped && Object.entries(result.skipped).some(([, count]) => count > 0) && (
+                  <div className="space-y-2">
+                    <h4 className="text-sm font-medium text-bambu-gray flex items-center gap-2">
+                      <SkipForward className="w-4 h-4 text-yellow-500" />
+                      Skipped (already exist)
+                    </h4>
+                    <div className="space-y-1">
+                      {Object.entries(result.skipped)
+                        .filter(([, count]) => count > 0)
+                        .map(([key, count]) => {
+                          const details = result.skipped_details?.[key] || [];
+                          const isExpanded = expandedCategories.has(key);
+                          return (
+                            <div key={key}>
+                              <button
+                                onClick={() => details.length > 0 && toggleCategory(key)}
+                                className={`w-full flex items-center justify-between text-sm p-2 rounded bg-bambu-dark ${
+                                  details.length > 0 ? 'hover:bg-bambu-dark-tertiary cursor-pointer' : ''
+                                }`}
+                              >
+                                <span className="text-white flex items-center gap-2">
+                                  {CATEGORY_LABELS[key] || key}
+                                  {details.length > 0 && (
+                                    isExpanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />
+                                  )}
+                                </span>
+                                <span className="text-yellow-500 font-medium">{count}</span>
+                              </button>
+                              {isExpanded && details.length > 0 && (
+                                <div className="mt-1 ml-4 p-2 rounded bg-bambu-dark-tertiary text-xs text-bambu-gray space-y-1">
+                                  {details.slice(0, 10).map((item, i) => (
+                                    <div key={i}>{item}</div>
+                                  ))}
+                                  {details.length > 10 && (
+                                    <div className="text-bambu-gray/60">...and {details.length - 10} more</div>
+                                  )}
+                                </div>
+                              )}
+                            </div>
+                          );
+                        })}
+                    </div>
+                  </div>
+                )}
+
+                {totalRestored === 0 && totalSkipped === 0 && (
+                  <div className="p-4 text-center text-bambu-gray">
+                    No data was found to restore in the backup file.
+                  </div>
+                )}
+              </div>
+
+              {/* Footer */}
+              <div className="flex items-center justify-end gap-3 p-4 border-t border-bambu-dark-tertiary">
+                <Button onClick={onClose}>
+                  Close
+                </Button>
+              </div>
+            </>
+          )}
+        </CardContent>
+      </Card>
+    </div>
+  );
+}

+ 21 - 24
frontend/src/components/SpoolmanSettings.tsx

@@ -37,9 +37,9 @@ export function SpoolmanSettings() {
   const [localEnabled, setLocalEnabled] = useState(false);
   const [localUrl, setLocalUrl] = useState('');
   const [localSyncMode, setLocalSyncMode] = useState('auto');
-  const [hasChanges, setHasChanges] = useState(false);
   const [showSaved, setShowSaved] = useState(false);
   const [selectedPrinterId, setSelectedPrinterId] = useState<number | 'all'>('all');
+  const [isInitialized, setIsInitialized] = useState(false);
 
   // Fetch Spoolman settings
   const { data: settings, isLoading: settingsLoading } = useQuery({
@@ -66,19 +66,26 @@ export function SpoolmanSettings() {
       setLocalEnabled(settings.spoolman_enabled === 'true');
       setLocalUrl(settings.spoolman_url || '');
       setLocalSyncMode(settings.spoolman_sync_mode || 'auto');
+      setIsInitialized(true);
     }
   }, [settings]);
 
-  // Track changes
+  // Auto-save when settings change (after initial load)
   useEffect(() => {
-    if (settings) {
-      const changed =
-        (settings.spoolman_enabled === 'true') !== localEnabled ||
-        (settings.spoolman_url || '') !== localUrl ||
-        (settings.spoolman_sync_mode || 'auto') !== localSyncMode;
-      setHasChanges(changed);
+    if (!isInitialized || !settings) return;
+
+    const hasChanges =
+      (settings.spoolman_enabled === 'true') !== localEnabled ||
+      (settings.spoolman_url || '') !== localUrl ||
+      (settings.spoolman_sync_mode || 'auto') !== localSyncMode;
+
+    if (hasChanges) {
+      const timeoutId = setTimeout(() => {
+        saveMutation.mutate();
+      }, 500);
+      return () => clearTimeout(timeoutId);
     }
-  }, [settings, localEnabled, localUrl, localSyncMode]);
+  }, [localEnabled, localUrl, localSyncMode, isInitialized]);
 
   // Save mutation
   const saveMutation = useMutation({
@@ -91,7 +98,6 @@ export function SpoolmanSettings() {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['spoolman-settings'] });
       queryClient.invalidateQueries({ queryKey: ['spoolman-status'] });
-      setHasChanges(false);
       setShowSaved(true);
       setTimeout(() => setShowSaved(false), 2000);
     },
@@ -173,20 +179,11 @@ export function SpoolmanSettings() {
             <Database className="w-5 h-5 text-bambu-green" />
             <h2 className="text-lg font-semibold text-white">Spoolman Integration</h2>
           </div>
-          {hasChanges && (
-            <Button
-              size="sm"
-              onClick={() => saveMutation.mutate()}
-              disabled={saveMutation.isPending}
-            >
-              {saveMutation.isPending ? (
-                <Loader2 className="w-4 h-4 animate-spin" />
-              ) : showSaved ? (
-                <Check className="w-4 h-4" />
-              ) : (
-                'Save'
-              )}
-            </Button>
+          {saveMutation.isPending && (
+            <Loader2 className="w-4 h-4 text-bambu-green animate-spin" />
+          )}
+          {showSaved && (
+            <Check className="w-4 h-4 text-bambu-green" />
           )}
         </div>
       </CardHeader>

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

@@ -30,7 +30,7 @@ export function CameraPage() {
       document.title = `${printer.name} - Camera`;
     }
     return () => {
-      document.title = 'Bambusy';
+      document.title = 'Bambuddy';
     };
   }, [printer]);
 

+ 61 - 61
frontend/src/pages/SettingsPage.tsx

@@ -12,6 +12,8 @@ import { AddNotificationModal } from '../components/AddNotificationModal';
 import { NotificationTemplateEditor } from '../components/NotificationTemplateEditor';
 import { NotificationLogViewer } from '../components/NotificationLogViewer';
 import { ConfirmModal } from '../components/ConfirmModal';
+import { BackupModal } from '../components/BackupModal';
+import { RestoreModal } from '../components/RestoreModal';
 import { SpoolmanSettings } from '../components/SpoolmanSettings';
 import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
 import { availableLanguages } from '../i18n';
@@ -30,13 +32,14 @@ export function SettingsPage() {
   const [editingTemplate, setEditingTemplate] = useState<NotificationTemplate | null>(null);
   const [showLogViewer, setShowLogViewer] = useState(false);
   const [defaultView, setDefaultViewState] = useState<string>(getDefaultView());
-  const fileInputRef = useRef<HTMLInputElement>(null);
   const [activeTab, setActiveTab] = useState<'general' | 'plugs' | 'notifications'>('general');
 
   // Confirm modal states
   const [showClearLogsConfirm, setShowClearLogsConfirm] = useState(false);
   const [showClearStorageConfirm, setShowClearStorageConfirm] = useState(false);
   const [showBulkPlugConfirm, setShowBulkPlugConfirm] = useState<'on' | 'off' | null>(null);
+  const [showBackupModal, setShowBackupModal] = useState(false);
+  const [showRestoreModal, setShowRestoreModal] = useState(false);
 
   const handleDefaultViewChange = (path: string) => {
     setDefaultViewState(path);
@@ -311,7 +314,7 @@ export function SettingsPage() {
     <div className="p-8">
       <div className="mb-8">
         <h1 className="text-2xl font-bold text-white">Settings</h1>
-        <p className="text-bambu-gray">Configure Bambusy</p>
+        <p className="text-bambu-gray">Configure Bambuddy</p>
       </div>
 
       {/* Tab Navigation */}
@@ -866,29 +869,15 @@ export function SettingsPage() {
               {/* Backup/Restore */}
               <div className="flex items-center justify-between">
                 <div>
-                  <p className="text-white">Backup Settings</p>
+                  <p className="text-white">Backup Data</p>
                   <p className="text-sm text-bambu-gray">
-                    Export settings, providers, and plugs to JSON
+                    Export settings, providers, printers, and more
                   </p>
                 </div>
                 <Button
                   variant="secondary"
                   size="sm"
-                  onClick={async () => {
-                    try {
-                      const backup = await api.exportBackup();
-                      const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' });
-                      const url = URL.createObjectURL(blob);
-                      const a = document.createElement('a');
-                      a.href = url;
-                      a.download = `bambutrack-backup-${new Date().toISOString().slice(0, 10)}.json`;
-                      a.click();
-                      URL.revokeObjectURL(url);
-                      showToast('Backup downloaded', 'success');
-                    } catch (err) {
-                      showToast('Failed to create backup', 'error');
-                    }
-                  }}
+                  onClick={() => setShowBackupModal(true)}
                 >
                   <Download className="w-4 h-4" />
                   Export
@@ -896,43 +885,19 @@ export function SettingsPage() {
               </div>
               <div className="flex items-center justify-between">
                 <div>
-                  <p className="text-white">Restore Settings</p>
+                  <p className="text-white">Restore Backup</p>
                   <p className="text-sm text-bambu-gray">
-                    Import settings from a backup file
+                    Import settings from a backup file with duplicate handling options
                   </p>
                 </div>
-                <div>
-                  <input
-                    ref={fileInputRef}
-                    type="file"
-                    accept=".json"
-                    className="hidden"
-                    onChange={async (e) => {
-                      const file = e.target.files?.[0];
-                      if (!file) return;
-                      try {
-                        const result = await api.importBackup(file);
-                        if (result.success) {
-                          showToast(result.message, 'success');
-                          queryClient.invalidateQueries();
-                        } else {
-                          showToast(result.message, 'error');
-                        }
-                      } catch (err) {
-                        showToast('Failed to restore backup', 'error');
-                      }
-                      e.target.value = '';
-                    }}
-                  />
-                  <Button
-                    variant="secondary"
-                    size="sm"
-                    onClick={() => fileInputRef.current?.click()}
-                  >
-                    <Upload className="w-4 h-4" />
-                    Import
-                  </Button>
-                </div>
+                <Button
+                  variant="secondary"
+                  size="sm"
+                  onClick={() => setShowRestoreModal(true)}
+                >
+                  <Upload className="w-4 h-4" />
+                  Restore
+                </Button>
               </div>
 
               <div className="border-t border-bambu-dark-tertiary pt-4">
@@ -955,9 +920,9 @@ export function SettingsPage() {
               </div>
               <div className="flex items-center justify-between">
                 <div>
-                  <p className="text-white">Clear Local Storage</p>
+                  <p className="text-white">Reset UI Preferences</p>
                   <p className="text-sm text-bambu-gray">
-                    Clear browser cache (sidebar order, preferences)
+                    Reset sidebar order, theme, view modes, and layout preferences. Printers, archives, and settings are not affected.
                   </p>
                 </div>
                 <Button
@@ -966,7 +931,7 @@ export function SettingsPage() {
                   onClick={() => setShowClearStorageConfirm(true)}
                 >
                   <Trash2 className="w-4 h-4" />
-                  Clear
+                  Reset
                 </Button>
               </div>
             </CardContent>
@@ -1435,14 +1400,14 @@ export function SettingsPage() {
       {/* Confirm Modal: Clear Local Storage */}
       {showClearStorageConfirm && (
         <ConfirmModal
-          title="Clear All Local Storage"
-          message="WARNING: This will clear ALL browser data for Bambusy including your sidebar order, preferences, and cached data. The page will reload after clearing. This action cannot be undone!"
-          confirmText="Clear Everything"
-          variant="danger"
+          title="Reset UI Preferences"
+          message="This will reset all UI preferences to defaults: sidebar order, theme, dashboard layout, view modes, and sorting preferences. Your printers, archives, and server settings will NOT be affected. The page will reload after clearing."
+          confirmText="Reset Preferences"
+          variant="default"
           onConfirm={() => {
             setShowClearStorageConfirm(false);
             localStorage.clear();
-            showToast('Local storage cleared. Refreshing...', 'success');
+            showToast('UI preferences reset. Refreshing...', 'success');
             setTimeout(() => window.location.reload(), 1000);
           }}
           onCancel={() => setShowClearStorageConfirm(false)}
@@ -1464,6 +1429,41 @@ export function SettingsPage() {
           onCancel={() => setShowBulkPlugConfirm(null)}
         />
       )}
+
+      {/* Backup Modal */}
+      {showBackupModal && (
+        <BackupModal
+          onClose={() => setShowBackupModal(false)}
+          onExport={async (categories) => {
+            setShowBackupModal(false);
+            try {
+              const { blob, filename } = await api.exportBackup(categories);
+              const url = URL.createObjectURL(blob);
+              const a = document.createElement('a');
+              a.href = url;
+              a.download = filename;
+              a.click();
+              URL.revokeObjectURL(url);
+              showToast('Backup downloaded', 'success');
+            } catch (err) {
+              showToast('Failed to create backup', 'error');
+            }
+          }}
+        />
+      )}
+
+      {/* Restore Modal */}
+      {showRestoreModal && (
+        <RestoreModal
+          onClose={() => setShowRestoreModal(false)}
+          onRestore={async (file, overwrite) => {
+            return await api.importBackup(file, overwrite);
+          }}
+          onSuccess={() => {
+            queryClient.invalidateQueries();
+          }}
+        />
+      )}
     </div>
   );
 }

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-Crbfjp9b.css


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-CycmYzoY.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-Ob3MFXab.css


BIN
static/img/android-chrome-192x192.png


BIN
static/img/android-chrome-512x512.png


BIN
static/img/apple-touch-icon.png


BIN
static/img/bambuddy_logo_dark.png


BIN
static/img/bambuddy_logo_light.png


BIN
static/img/bambusy_logo_dark.png


BIN
static/img/bambusy_logo_light.png


BIN
static/img/favicon-16x16.png


BIN
static/img/favicon-32x32.png


BIN
static/img/favicon.png


+ 3 - 3
static/index.html

@@ -3,12 +3,12 @@
   <head>
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>Bambusy</title>
+    <title>Bambuddy</title>
     <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-E695Z6RQ.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-Crbfjp9b.css">
+    <script type="module" crossorigin src="/assets/index-CycmYzoY.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-Ob3MFXab.css">
   </head>
   <body>
     <div id="root"></div>

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio