Browse Source

Merge branch '0.1.6-final' into feature/auth_details

MartinNYHC 3 months ago
parent
commit
391214be79
40 changed files with 5237 additions and 297 deletions
  1. 19 18
      CHANGELOG.md
  2. 4 1
      README.md
  3. 112 3
      backend/app/api/routes/archives.py
  4. 60 1
      backend/app/api/routes/settings.py
  5. 286 15
      backend/app/api/routes/smart_plugs.py
  6. 117 23
      backend/app/core/database.py
  7. 34 1
      backend/app/main.py
  8. 31 2
      backend/app/models/smart_plug.py
  9. 58 6
      backend/app/schemas/smart_plug.py
  10. 1 1
      backend/app/services/homeassistant.py
  11. 32 1
      backend/app/services/mqtt_relay.py
  12. 488 0
      backend/app/services/mqtt_smart_plug.py
  13. 8 4
      backend/app/services/print_scheduler.py
  14. 11 6
      backend/app/services/smart_plug_manager.py
  15. 34 0
      backend/tests/conftest.py
  16. 124 0
      backend/tests/integration/test_archives_api.py
  17. 252 0
      backend/tests/integration/test_smart_plugs_api.py
  18. 48 18
      deploy/bambuddy.service
  19. 4 0
      frontend/src/App.tsx
  20. 5 1
      frontend/src/__tests__/components/EditArchiveModal.test.tsx
  21. 87 0
      frontend/src/__tests__/components/SmartPlugCard.test.tsx
  22. 300 0
      frontend/src/__tests__/components/TagManagementModal.test.tsx
  23. 230 0
      frontend/src/__tests__/pages/StreamOverlayPage.test.tsx
  24. 75 8
      frontend/src/api/client.ts
  25. 275 66
      frontend/src/components/AddSmartPlugModal.tsx
  26. 33 14
      frontend/src/components/EditArchiveModal.tsx
  27. 126 71
      frontend/src/components/SmartPlugCard.tsx
  28. 60 30
      frontend/src/components/SwitchbarPopover.tsx
  29. 294 0
      frontend/src/components/TagManagementModal.tsx
  30. 15 0
      frontend/src/pages/ArchivesPage.tsx
  31. 37 0
      frontend/src/pages/PrintersPage.tsx
  32. 11 7
      frontend/src/pages/SettingsPage.tsx
  33. 308 0
      frontend/src/pages/StreamOverlayPage.tsx
  34. 234 0
      install/README.md
  35. 541 0
      install/docker-install.sh
  36. 883 0
      install/install.sh
  37. 0 0
      static/assets/index-BJQC4Kk-.js
  38. 0 0
      static/assets/index-BTpjfCpx.css
  39. 0 0
      static/assets/index-C1mIxrzF.css
  40. 0 0
      static/assets/index-wxwVsx5u.js

+ 19 - 18
CHANGELOG.md

@@ -21,6 +21,25 @@ All notable changes to Bambuddy will be documented in this file.
   - Uses trimesh and matplotlib for 3D rendering with Bambu green color theme
   - Thumbnails auto-refresh in UI after generation
   - Graceful handling of complex/invalid STL files
+- **Streaming Overlay for OBS** - Embeddable overlay page for live streaming with camera and print status (Issue #164):
+  - All-in-one page at `/overlay/:printerId` combining camera feed with status overlay
+  - Real-time print progress, ETA, layer count, and filename display
+  - Bambuddy logo branding (links to GitHub)
+  - Customizable via query parameters: `?size=small|medium|large` and `?show=progress,layers,eta,filename,status,printer`
+  - No authentication required - designed for OBS browser source embedding
+  - Gradient overlay at bottom for readable text over camera feed
+  - Auto-reconnect on camera stream errors
+- **MQTT Smart Plug Support** - Add smart plugs that subscribe to MQTT topics for energy monitoring (Issue #173):
+  - New "MQTT" plug type alongside Tasmota and Home Assistant
+  - Subscribe to any MQTT topic (Zigbee2MQTT, Shelly, Tasmota discovery, etc.)
+  - **Separate topics per data type**: Configure different MQTT topics for power, energy, and state
+  - Configurable JSON paths for data extraction (e.g., `power_l1`, `data.power`)
+  - **Separate multipliers**: Individual multiplier for power and energy (e.g., mW→W, Wh→kWh)
+  - **Custom ON value**: Configure what value means "ON" for state (e.g., "ON", "true", "1")
+  - Monitor-only: displays power/energy data without control capabilities
+  - Reuses existing MQTT broker settings from Settings → Network
+  - Energy data included in statistics and per-print tracking
+  - Full backup/restore support for MQTT plug configurations
 - **Disable Printer Firmware Checks** - New toggle in Settings → General → Updates to disable printer firmware update checks:
   - Prevents Bambuddy from checking Bambu Lab servers for firmware updates
   - Useful for users who prefer to manage firmware manually or have network restrictions
@@ -106,24 +125,6 @@ All notable changes to Bambuddy will be documented in this file.
   - Bulk edit: printer assignment, print options, queue options
   - Bulk cancel selected items
   - Tri-state toggles: unchanged / on / off for each setting
-- **Model-Based Queue Assignment** - Queue prints to "any printer of matching model" for load balancing (Issue #162):
-  - Extract printer model from sliced 3MF files (e.g., "X1C", "P1S")
-  - Display sliced-for model in archive view
-  - New queue mode: assign to model instead of specific printer
-  - Scheduler auto-assigns to first idle printer of matching model
-  - Filament validation: only assign to printers with required filament types loaded
-  - Waiting reason display: shows why jobs are waiting (e.g., "Waiting for filament: Printer1 (needs PLA)")
-  - "Waiting" status badge (purple) distinguishes from regular "Pending"
-  - Compatibility warnings when file/printer model mismatch
-- **Queue Notifications** - Get notified about print queue events:
-  - Job Added: When a job is added to the queue
-  - Job Assigned: When a model-based job is assigned to a printer
-  - Job Started: When a queue job starts printing
-  - Job Waiting: When a job is waiting for filament (enabled by default)
-  - Job Skipped: When a job is skipped due to previous failure (enabled by default)
-  - Job Failed: When a job fails to start (enabled by default)
-  - Queue Complete: When all queued jobs finish
-  - New "Print Queue" section in notification provider settings
 
 ### Fixes
 - **Multi-Plate Thumbnail in Queue** - Fixed queue items showing wrong thumbnail for multi-plate files (Issue #166):

+ 4 - 1
README.md

@@ -54,10 +54,12 @@
 - Re-print to any connected printer with AMS mapping (auto-match or manual slot selection, multi-plate support)
 - Plate thumbnail browsing for multi-plate archives (hover to navigate between plates)
 - Archive comparison (side-by-side diff)
+- Tag management (rename/delete across all archives)
 
 ### 📊 Monitoring & Control
 - Real-time printer status via WebSocket
 - Live camera streaming (MJPEG) & snapshots with multi-viewer support
+- **Streaming overlay for OBS** - Embeddable page with camera + status for live streaming (`/overlay/:printerId`)
 - External camera support (MJPEG, RTSP, HTTP snapshot, USB/V4L2) with layer-based timelapse
 - **Build plate empty detection** - Auto-pause print if objects detected on plate (multi-reference calibration, ROI adjustment)
 - Fan status monitoring (part cooling, auxiliary, chamber)
@@ -80,7 +82,8 @@
 - Per-printer AMS mapping (individual slot configuration for print farms)
 - Scheduled prints (date/time)
 - Queue Only mode (stage without auto-start)
-- Smart plug integration (Tasmota, Home Assistant)
+- Smart plug integration (Tasmota, Home Assistant, MQTT)
+- MQTT smart plugs: Subscribe to Zigbee2MQTT, Shelly, or any MQTT topic for energy monitoring
 - Energy consumption tracking (per-print kWh and cost)
 - HA energy sensor support (for plugs with separate power/energy sensors)
 - Auto power-on before print

+ 112 - 3
backend/app/api/routes/archives.py

@@ -515,6 +515,8 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)):
     if energy_tracking_mode == "total":
         # Total mode: sum up 'total' counter from all smart plugs (lifetime consumption)
         from backend.app.models.smart_plug import SmartPlug
+        from backend.app.services.homeassistant import homeassistant_service
+        from backend.app.services.mqtt_relay import mqtt_relay
         from backend.app.services.tasmota import tasmota_service
 
         plugs_result = await db.execute(select(SmartPlug))
@@ -522,9 +524,19 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)):
 
         total_energy_kwh = 0.0
         for plug in plugs:
-            energy = await tasmota_service.get_energy(plug)
-            if energy and energy.get("total") is not None:
-                total_energy_kwh += energy["total"]
+            if plug.plug_type == "tasmota":
+                energy = await tasmota_service.get_energy(plug)
+                if energy and energy.get("total") is not None:
+                    total_energy_kwh += energy["total"]
+            elif plug.plug_type == "homeassistant":
+                energy = await homeassistant_service.get_energy(plug)
+                if energy and energy.get("total") is not None:
+                    total_energy_kwh += energy["total"]
+            elif plug.plug_type == "mqtt":
+                # MQTT plugs report "today" energy, not lifetime total
+                mqtt_data = mqtt_relay.smart_plug_service.get_plug_data(plug.id)
+                if mqtt_data and mqtt_data.energy is not None:
+                    total_energy_kwh += mqtt_data.energy
 
         total_energy_kwh = round(total_energy_kwh, 3)
         total_energy_cost = round(total_energy_kwh * energy_cost_per_kwh, 2)
@@ -552,6 +564,103 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)):
     )
 
 
+@router.get("/tags")
+async def get_all_tags(db: AsyncSession = Depends(get_db)):
+    """List all unique tags with usage counts.
+
+    Returns a list of tags sorted by count (descending), then by name.
+    """
+    # Query all archives with non-null tags
+    result = await db.execute(select(PrintArchive.tags).where(PrintArchive.tags.isnot(None)))
+    all_tags_rows = result.all()
+
+    # Count occurrences of each tag
+    tag_counts: dict[str, int] = {}
+    for (tags_str,) in all_tags_rows:
+        if tags_str:
+            for tag in tags_str.split(","):
+                tag = tag.strip()
+                if tag:
+                    tag_counts[tag] = tag_counts.get(tag, 0) + 1
+
+    # Convert to list and sort by count (desc), then name (asc)
+    tags_list = [{"name": name, "count": count} for name, count in tag_counts.items()]
+    tags_list.sort(key=lambda x: (-x["count"], x["name"].lower()))
+
+    return tags_list
+
+
+@router.put("/tags/{tag_name}")
+async def rename_tag(
+    tag_name: str,
+    request: Request,
+    db: AsyncSession = Depends(get_db),
+):
+    """Rename a tag across all archives.
+
+    Request body should contain {"new_name": "new tag name"}.
+    Returns the count of affected archives.
+    """
+    body = await request.json()
+    new_name = body.get("new_name", "").strip()
+
+    if not new_name:
+        raise HTTPException(400, "new_name is required")
+
+    if new_name == tag_name:
+        return {"affected": 0}
+
+    # Find all archives containing the old tag
+    result = await db.execute(select(PrintArchive).where(PrintArchive.tags.isnot(None)))
+    archives = list(result.scalars().all())
+
+    affected = 0
+    for archive in archives:
+        if not archive.tags:
+            continue
+        tags = [t.strip() for t in archive.tags.split(",")]
+        if tag_name in tags:
+            # Replace old tag with new tag
+            new_tags = [new_name if t == tag_name else t for t in tags]
+            # Remove duplicates while preserving order
+            seen = set()
+            unique_tags = []
+            for t in new_tags:
+                if t not in seen:
+                    seen.add(t)
+                    unique_tags.append(t)
+            archive.tags = ", ".join(unique_tags)
+            affected += 1
+
+    await db.commit()
+    return {"affected": affected}
+
+
+@router.delete("/tags/{tag_name}")
+async def delete_tag(tag_name: str, db: AsyncSession = Depends(get_db)):
+    """Delete a tag from all archives.
+
+    Returns the count of affected archives.
+    """
+    # Find all archives containing the tag
+    result = await db.execute(select(PrintArchive).where(PrintArchive.tags.isnot(None)))
+    archives = list(result.scalars().all())
+
+    affected = 0
+    for archive in archives:
+        if not archive.tags:
+            continue
+        tags = [t.strip() for t in archive.tags.split(",")]
+        if tag_name in tags:
+            # Remove the tag
+            new_tags = [t for t in tags if t != tag_name]
+            archive.tags = ", ".join(new_tags) if new_tags else None
+            affected += 1
+
+    await db.commit()
+    return {"affected": affected}
+
+
 @router.get("/{archive_id}", response_model=ArchiveResponse)
 async def get_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
     """Get a specific archive."""

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

@@ -369,6 +369,21 @@ async def export_backup(
                     "ha_power_entity": plug.ha_power_entity,
                     "ha_energy_today_entity": plug.ha_energy_today_entity,
                     "ha_energy_total_entity": plug.ha_energy_total_entity,
+                    # MQTT plug fields (legacy)
+                    "mqtt_topic": plug.mqtt_topic,
+                    "mqtt_multiplier": plug.mqtt_multiplier,
+                    # MQTT power fields
+                    "mqtt_power_topic": plug.mqtt_power_topic,
+                    "mqtt_power_path": plug.mqtt_power_path,
+                    "mqtt_power_multiplier": plug.mqtt_power_multiplier,
+                    # MQTT energy fields
+                    "mqtt_energy_topic": plug.mqtt_energy_topic,
+                    "mqtt_energy_path": plug.mqtt_energy_path,
+                    "mqtt_energy_multiplier": plug.mqtt_energy_multiplier,
+                    # MQTT state fields
+                    "mqtt_state_topic": plug.mqtt_state_topic,
+                    "mqtt_state_path": plug.mqtt_state_path,
+                    "mqtt_state_on_value": plug.mqtt_state_on_value,
                     "printer_serial": printer_id_to_serial.get(plug.printer_id) if plug.printer_id else None,
                     "enabled": plug.enabled,
                     "auto_on": plug.auto_on,
@@ -385,6 +400,7 @@ async def export_backup(
                     "schedule_on_time": plug.schedule_on_time,
                     "schedule_off_time": plug.schedule_off_time,
                     "show_in_switchbar": plug.show_in_switchbar,
+                    "show_on_printer_card": plug.show_on_printer_card,
                 }
             )
         backup["included"].append("smart_plugs")
@@ -1300,12 +1316,23 @@ async def import_backup(
             # Determine plug type (default to tasmota for backwards compatibility)
             plug_type = plug_data.get("plug_type", "tasmota")
 
-            # Find existing plug by IP (Tasmota) or entity_id (Home Assistant)
+            # Find existing plug by IP (Tasmota), entity_id (Home Assistant), or mqtt_topic (MQTT)
             existing = None
+            plug_identifier = None
             if plug_type == "homeassistant" and plug_data.get("ha_entity_id"):
                 result = await db.execute(select(SmartPlug).where(SmartPlug.ha_entity_id == plug_data["ha_entity_id"]))
                 existing = result.scalar_one_or_none()
                 plug_identifier = plug_data["ha_entity_id"]
+            elif plug_type == "mqtt" and (plug_data.get("mqtt_power_topic") or plug_data.get("mqtt_topic")):
+                # Check by mqtt_power_topic first (new format), fall back to mqtt_topic (legacy)
+                power_topic = plug_data.get("mqtt_power_topic") or plug_data.get("mqtt_topic")
+                result = await db.execute(
+                    select(SmartPlug).where(
+                        (SmartPlug.mqtt_power_topic == power_topic) | (SmartPlug.mqtt_topic == power_topic)
+                    )
+                )
+                existing = result.scalar_one_or_none()
+                plug_identifier = power_topic
             elif plug_data.get("ip_address"):
                 result = await db.execute(select(SmartPlug).where(SmartPlug.ip_address == plug_data["ip_address"]))
                 existing = result.scalar_one_or_none()
@@ -1322,6 +1349,21 @@ async def import_backup(
                     existing.ha_power_entity = plug_data.get("ha_power_entity")
                     existing.ha_energy_today_entity = plug_data.get("ha_energy_today_entity")
                     existing.ha_energy_total_entity = plug_data.get("ha_energy_total_entity")
+                    # MQTT fields (legacy)
+                    existing.mqtt_topic = plug_data.get("mqtt_topic")
+                    existing.mqtt_multiplier = plug_data.get("mqtt_multiplier", 1.0)
+                    # MQTT power fields
+                    existing.mqtt_power_topic = plug_data.get("mqtt_power_topic")
+                    existing.mqtt_power_path = plug_data.get("mqtt_power_path")
+                    existing.mqtt_power_multiplier = plug_data.get("mqtt_power_multiplier", 1.0)
+                    # MQTT energy fields
+                    existing.mqtt_energy_topic = plug_data.get("mqtt_energy_topic")
+                    existing.mqtt_energy_path = plug_data.get("mqtt_energy_path")
+                    existing.mqtt_energy_multiplier = plug_data.get("mqtt_energy_multiplier", 1.0)
+                    # MQTT state fields
+                    existing.mqtt_state_topic = plug_data.get("mqtt_state_topic")
+                    existing.mqtt_state_path = plug_data.get("mqtt_state_path")
+                    existing.mqtt_state_on_value = plug_data.get("mqtt_state_on_value")
                     existing.printer_id = printer_id
                     existing.enabled = plug_data.get("enabled", True)
                     existing.auto_on = plug_data.get("auto_on", True)
@@ -1338,6 +1380,7 @@ async def import_backup(
                     existing.schedule_on_time = plug_data.get("schedule_on_time")
                     existing.schedule_off_time = plug_data.get("schedule_off_time")
                     existing.show_in_switchbar = plug_data.get("show_in_switchbar", False)
+                    existing.show_on_printer_card = plug_data.get("show_on_printer_card", True)
                     restored["smart_plugs"] += 1
                 else:
                     skipped["smart_plugs"] += 1
@@ -1351,6 +1394,21 @@ async def import_backup(
                     ha_power_entity=plug_data.get("ha_power_entity"),
                     ha_energy_today_entity=plug_data.get("ha_energy_today_entity"),
                     ha_energy_total_entity=plug_data.get("ha_energy_total_entity"),
+                    # MQTT fields (legacy)
+                    mqtt_topic=plug_data.get("mqtt_topic"),
+                    mqtt_multiplier=plug_data.get("mqtt_multiplier", 1.0),
+                    # MQTT power fields
+                    mqtt_power_topic=plug_data.get("mqtt_power_topic"),
+                    mqtt_power_path=plug_data.get("mqtt_power_path"),
+                    mqtt_power_multiplier=plug_data.get("mqtt_power_multiplier", 1.0),
+                    # MQTT energy fields
+                    mqtt_energy_topic=plug_data.get("mqtt_energy_topic"),
+                    mqtt_energy_path=plug_data.get("mqtt_energy_path"),
+                    mqtt_energy_multiplier=plug_data.get("mqtt_energy_multiplier", 1.0),
+                    # MQTT state fields
+                    mqtt_state_topic=plug_data.get("mqtt_state_topic"),
+                    mqtt_state_path=plug_data.get("mqtt_state_path"),
+                    mqtt_state_on_value=plug_data.get("mqtt_state_on_value"),
                     printer_id=printer_id,
                     enabled=plug_data.get("enabled", True),
                     auto_on=plug_data.get("auto_on", True),
@@ -1367,6 +1425,7 @@ async def import_backup(
                     schedule_on_time=plug_data.get("schedule_on_time"),
                     schedule_off_time=plug_data.get("schedule_off_time"),
                     show_in_switchbar=plug_data.get("show_in_switchbar", False),
+                    show_on_printer_card=plug_data.get("show_on_printer_card", True),
                 )
                 db.add(plug)
                 restored["smart_plugs"] += 1

+ 286 - 15
backend/app/api/routes/smart_plugs.py

@@ -27,6 +27,7 @@ from backend.app.schemas.smart_plug import (
 )
 from backend.app.services.discovery import tasmota_scanner
 from backend.app.services.homeassistant import homeassistant_service
+from backend.app.services.mqtt_relay import mqtt_relay
 from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.tasmota import tasmota_service
@@ -56,16 +57,84 @@ async def create_smart_plug(
             raise HTTPException(400, "Printer not found")
 
         # Check if printer already has a plug assigned
-        result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == data.printer_id))
-        if result.scalar_one_or_none():
-            raise HTTPException(400, "This printer already has a smart plug assigned")
+        # Scripts can coexist with other plugs (they're for multi-device control, not power on/off)
+        is_script = data.plug_type == "homeassistant" and data.ha_entity_id and data.ha_entity_id.startswith("script.")
+        if not is_script:
+            # For non-script plugs, check there's no other non-script plug assigned
+            result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == data.printer_id))
+            existing = result.scalar_one_or_none()
+            if existing:
+                # Allow if existing plug is a script
+                existing_is_script = (
+                    existing.plug_type == "homeassistant"
+                    and existing.ha_entity_id
+                    and existing.ha_entity_id.startswith("script.")
+                )
+                if not existing_is_script:
+                    raise HTTPException(400, "This printer already has a smart plug assigned")
+
+    # For MQTT plugs, ensure MQTT broker is configured and service is connected
+    if data.plug_type == "mqtt":
+        # Try to configure the smart plug service if not already configured
+        if not mqtt_relay.smart_plug_service.is_configured():
+            # Get MQTT broker settings from database
+            mqtt_broker = await get_setting(db, "mqtt_broker") or ""
+            if not mqtt_broker:
+                raise HTTPException(
+                    400,
+                    "MQTT broker not configured. Please set MQTT broker address in Settings → Network → MQTT Publishing.",
+                )
+
+            # Configure the smart plug service with broker settings
+            mqtt_settings = {
+                "mqtt_enabled": True,  # Enable for smart plug subscription
+                "mqtt_broker": mqtt_broker,
+                "mqtt_port": int(await get_setting(db, "mqtt_port") or "1883"),
+                "mqtt_username": await get_setting(db, "mqtt_username") or "",
+                "mqtt_password": await get_setting(db, "mqtt_password") or "",
+                "mqtt_use_tls": (await get_setting(db, "mqtt_use_tls") or "false") == "true",
+            }
+            await mqtt_relay.smart_plug_service.configure(mqtt_settings)
+
+            # Check if connection succeeded
+            if not mqtt_relay.smart_plug_service.is_configured():
+                raise HTTPException(
+                    400,
+                    f"Failed to connect to MQTT broker at {mqtt_broker}. Please check your MQTT settings.",
+                )
 
     plug = SmartPlug(**data.model_dump())
     db.add(plug)
     await db.commit()
     await db.refresh(plug)
 
-    if plug.plug_type == "homeassistant":
+    # Subscribe MQTT plugs to their topics
+    if plug.plug_type == "mqtt":
+        # Determine effective topics (new fields take priority, fall back to legacy)
+        power_topic = plug.mqtt_power_topic or plug.mqtt_topic
+        energy_topic = plug.mqtt_energy_topic
+        state_topic = plug.mqtt_state_topic
+
+        # Only subscribe if at least one topic is configured
+        if power_topic or energy_topic or state_topic:
+            mqtt_relay.smart_plug_service.subscribe(
+                plug_id=plug.id,
+                # Power source (path is optional)
+                power_topic=power_topic,
+                power_path=plug.mqtt_power_path,
+                power_multiplier=plug.mqtt_power_multiplier or plug.mqtt_multiplier or 1.0,
+                # Energy source (path is optional)
+                energy_topic=energy_topic,
+                energy_path=plug.mqtt_energy_path,
+                energy_multiplier=plug.mqtt_energy_multiplier or plug.mqtt_multiplier or 1.0,
+                # State source (path is optional)
+                state_topic=state_topic,
+                state_path=plug.mqtt_state_path,
+                state_on_value=plug.mqtt_state_on_value,
+            )
+            topics = [t for t in [power_topic, energy_topic, state_topic] if t]
+            logger.info(f"Created MQTT plug '{plug.name}' subscribed to {', '.join(set(topics))}")
+    elif plug.plug_type == "homeassistant":
         logger.info(f"Created Home Assistant plug '{plug.name}' ({plug.ha_entity_id})")
     else:
         logger.info(f"Created Tasmota plug '{plug.name}' at {plug.ip_address}")
@@ -74,12 +143,48 @@ async def create_smart_plug(
 
 @router.get("/by-printer/{printer_id}", response_model=SmartPlugResponse | None)
 async def get_smart_plug_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
-    """Get the smart plug assigned to a printer."""
+    """Get the main smart plug assigned to a printer.
+
+    When multiple plugs are assigned (e.g., a regular plug + script),
+    returns the main (non-script) plug for power control.
+    """
     result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
-    plug = result.scalar_one_or_none()
-    if not plug:
+    plugs = result.scalars().all()
+
+    if not plugs:
         return None
-    return plug
+
+    # If multiple plugs, prefer the non-script one (main power plug)
+    for plug in plugs:
+        is_script = plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.")
+        if not is_script:
+            return plug
+
+    # All are scripts, return the first one
+    return plugs[0]
+
+
+@router.get("/by-printer/{printer_id}/scripts", response_model=list[SmartPlugResponse])
+async def get_script_plugs_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
+    """Get all HA script plugs assigned to a printer.
+
+    Returns only script entities (script.*) for the printer that have
+    show_on_printer_card enabled.
+    Used to display "Run Script" buttons alongside the main power plug.
+    """
+    result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
+    plugs = result.scalars().all()
+
+    # Filter to only scripts with show_on_printer_card enabled
+    scripts = [
+        plug
+        for plug in plugs
+        if plug.plug_type == "homeassistant"
+        and plug.ha_entity_id
+        and plug.ha_entity_id.startswith("script.")
+        and plug.show_on_printer_card
+    ]
+    return scripts
 
 
 # Tasmota Discovery Endpoints
@@ -287,14 +392,43 @@ async def update_smart_plug(
             raise HTTPException(400, "Printer not found")
 
         # Check if that printer already has a different plug assigned
-        result = await db.execute(
-            select(SmartPlug).where(
-                SmartPlug.printer_id == new_printer_id,
-                SmartPlug.id != plug_id,
+        # Scripts can coexist with other plugs
+        # Determine if the plug being updated is/will be a script
+        new_entity_id = update_data.get("ha_entity_id", plug.ha_entity_id)
+        new_plug_type = update_data.get("plug_type", plug.plug_type)
+        is_script = new_plug_type == "homeassistant" and new_entity_id and new_entity_id.startswith("script.")
+
+        if not is_script:
+            result = await db.execute(
+                select(SmartPlug).where(
+                    SmartPlug.printer_id == new_printer_id,
+                    SmartPlug.id != plug_id,
+                )
             )
-        )
-        if result.scalar_one_or_none():
-            raise HTTPException(400, "This printer already has a smart plug assigned")
+            existing = result.scalar_one_or_none()
+            if existing:
+                # Allow if existing plug is a script
+                existing_is_script = (
+                    existing.plug_type == "homeassistant"
+                    and existing.ha_entity_id
+                    and existing.ha_entity_id.startswith("script.")
+                )
+                if not existing_is_script:
+                    raise HTTPException(400, "This printer already has a smart plug assigned")
+
+    # Track old MQTT settings for comparison
+    old_plug_type = plug.plug_type
+    old_mqtt_config = {
+        "power_topic": plug.mqtt_power_topic or plug.mqtt_topic,
+        "power_path": plug.mqtt_power_path,
+        "power_multiplier": plug.mqtt_power_multiplier,
+        "energy_topic": plug.mqtt_energy_topic or plug.mqtt_topic,
+        "energy_path": plug.mqtt_energy_path,
+        "energy_multiplier": plug.mqtt_energy_multiplier,
+        "state_topic": plug.mqtt_state_topic or plug.mqtt_topic,
+        "state_path": plug.mqtt_state_path,
+        "state_on_value": plug.mqtt_state_on_value,
+    }
 
     for field, value in update_data.items():
         setattr(plug, field, value)
@@ -302,6 +436,54 @@ async def update_smart_plug(
     await db.commit()
     await db.refresh(plug)
 
+    # Handle MQTT subscription changes
+    if old_plug_type == "mqtt" and plug.plug_type != "mqtt":
+        # Changed away from MQTT - unsubscribe
+        mqtt_relay.smart_plug_service.unsubscribe(plug.id)
+    elif plug.plug_type == "mqtt":
+        # Check if any MQTT config changed
+        new_mqtt_config = {
+            "power_topic": plug.mqtt_power_topic or plug.mqtt_topic,
+            "power_path": plug.mqtt_power_path,
+            "power_multiplier": plug.mqtt_power_multiplier,
+            "energy_topic": plug.mqtt_energy_topic or plug.mqtt_topic,
+            "energy_path": plug.mqtt_energy_path,
+            "energy_multiplier": plug.mqtt_energy_multiplier,
+            "state_topic": plug.mqtt_state_topic or plug.mqtt_topic,
+            "state_path": plug.mqtt_state_path,
+            "state_on_value": plug.mqtt_state_on_value,
+        }
+
+        mqtt_changed = old_plug_type != "mqtt" or old_mqtt_config != new_mqtt_config
+
+        if mqtt_changed:
+            # Unsubscribe from old topics first
+            if old_plug_type == "mqtt":
+                mqtt_relay.smart_plug_service.unsubscribe(plug.id)
+
+            # Subscribe to new topics
+            power_topic = plug.mqtt_power_topic or plug.mqtt_topic
+            energy_topic = plug.mqtt_energy_topic
+            state_topic = plug.mqtt_state_topic
+
+            # Only subscribe if at least one topic is configured
+            if power_topic or energy_topic or state_topic:
+                mqtt_relay.smart_plug_service.subscribe(
+                    plug_id=plug.id,
+                    # Power source (path is optional)
+                    power_topic=power_topic,
+                    power_path=plug.mqtt_power_path,
+                    power_multiplier=plug.mqtt_power_multiplier or plug.mqtt_multiplier or 1.0,
+                    # Energy source (path is optional)
+                    energy_topic=energy_topic,
+                    energy_path=plug.mqtt_energy_path,
+                    energy_multiplier=plug.mqtt_energy_multiplier or plug.mqtt_multiplier or 1.0,
+                    # State source (path is optional)
+                    state_topic=state_topic,
+                    state_path=plug.mqtt_state_path,
+                    state_on_value=plug.mqtt_state_on_value,
+                )
+
     logger.info(f"Updated smart plug '{plug.name}'")
     return plug
 
@@ -315,6 +497,12 @@ async def delete_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)):
         raise HTTPException(404, "Smart plug not found")
 
     plug_name = plug.name
+    plug_type = plug.plug_type
+
+    # Unsubscribe MQTT plug before deletion
+    if plug_type == "mqtt":
+        mqtt_relay.smart_plug_service.unsubscribe(plug_id)
+
     await db.delete(plug)
     await db.commit()
 
@@ -348,6 +536,13 @@ async def control_smart_plug(
     if not plug:
         raise HTTPException(404, "Smart plug not found")
 
+    # MQTT plugs are monitor-only - cannot control them
+    if plug.plug_type == "mqtt":
+        raise HTTPException(
+            400,
+            "MQTT plugs are monitor-only. Use your MQTT broker or home automation system to control them.",
+        )
+
     service = await _get_service_for_plug(plug, db)
 
     if control.action == "on":
@@ -376,6 +571,13 @@ async def control_smart_plug(
     plug.last_checked = datetime.utcnow()
     await db.commit()
 
+    # Trigger associated scripts if this is a main (non-script) plug
+    is_main_plug = not (
+        plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.")
+    )
+    if is_main_plug and plug.printer_id and expected_state:
+        await trigger_associated_scripts(plug.printer_id, expected_state, db)
+
     # MQTT relay - publish smart plug state change
     if expected_state:
         try:
@@ -401,6 +603,37 @@ async def control_smart_plug(
     return {"success": True, "action": control.action}
 
 
+async def trigger_associated_scripts(printer_id: int, plug_state: str, db: AsyncSession):
+    """Trigger scripts linked to a printer based on main plug state change.
+
+    When the main plug turns ON, triggers scripts with auto_on=True.
+    When the main plug turns OFF, triggers scripts with auto_off=True.
+    """
+    result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
+    plugs = result.scalars().all()
+
+    # Find scripts that should be triggered
+    for plug in plugs:
+        is_script = plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.")
+        if not is_script:
+            continue
+
+        should_trigger = False
+        if plug_state == "ON" and plug.auto_on:
+            should_trigger = True
+            logger.info(f"Auto-triggering script '{plug.name}' on printer power-on")
+        elif plug_state == "OFF" and plug.auto_off:
+            should_trigger = True
+            logger.info(f"Auto-triggering script '{plug.name}' on printer power-off")
+
+        if should_trigger:
+            try:
+                service = await _get_service_for_plug(plug, db)
+                await service.turn_on(plug)  # Scripts are triggered by calling turn_on
+            except Exception as e:
+                logger.error(f"Failed to trigger script '{plug.name}': {e}")
+
+
 @router.get("/{plug_id}/status", response_model=SmartPlugStatus)
 async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)):
     """Get current plug status from device including energy data."""
@@ -409,6 +642,44 @@ async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)):
     if not plug:
         raise HTTPException(404, "Smart plug not found")
 
+    # Handle MQTT plugs - get data from subscription service
+    if plug.plug_type == "mqtt":
+        data = mqtt_relay.smart_plug_service.get_plug_data(plug_id)
+        is_reachable = mqtt_relay.smart_plug_service.is_reachable(plug_id)
+
+        if data:
+            # Update last state in database
+            if is_reachable and data.state:
+                plug.last_state = data.state
+                plug.last_checked = datetime.utcnow()
+                await db.commit()
+
+            energy_data = None
+            if data.power is not None or data.energy is not None:
+                energy_data = SmartPlugEnergy(
+                    power=data.power,
+                    today=data.energy,
+                )
+                # Check power alerts
+                if data.power is not None:
+                    await check_power_alerts(plug, data.power, db)
+
+            return SmartPlugStatus(
+                state=data.state,
+                reachable=is_reachable,
+                device_name=None,
+                energy=energy_data,
+            )
+
+        # No data received yet
+        return SmartPlugStatus(
+            state=None,
+            reachable=False,
+            device_name=None,
+            energy=None,
+        )
+
+    # Handle Tasmota/HomeAssistant plugs
     service = await _get_service_for_plug(plug, db)
     status = await service.get_status(plug)
 

+ 117 - 23
backend/app/core/database.py

@@ -764,45 +764,139 @@ async def run_migrations(conn):
     except Exception:
         pass
 
-    # Migration: Add sliced_for_model column to print_archives for printer model from 3MF
+    # Migration: Remove UNIQUE constraint from smart_plugs.printer_id
+    # This allows HA scripts to coexist with regular plugs (scripts are for multi-device control)
+    # SQLite requires table recreation to drop constraints
     try:
-        await conn.execute(text("ALTER TABLE print_archives ADD COLUMN sliced_for_model VARCHAR(50)"))
+        # Check if we need to migrate (if UNIQUE constraint exists)
+        result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='smart_plugs'"))
+        row = result.fetchone()
+        if row and "printer_id INTEGER UNIQUE" in (row[0] or ""):
+            # Create new table without UNIQUE constraint on printer_id
+            await conn.execute(
+                text("""
+                CREATE TABLE smart_plugs_temp (
+                    id INTEGER PRIMARY KEY,
+                    name VARCHAR(100) NOT NULL,
+                    ip_address VARCHAR(45),
+                    plug_type VARCHAR(20) DEFAULT 'tasmota',
+                    ha_entity_id VARCHAR(100),
+                    ha_power_entity VARCHAR(100),
+                    ha_energy_today_entity VARCHAR(100),
+                    ha_energy_total_entity VARCHAR(100),
+                    printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL,
+                    enabled BOOLEAN NOT NULL DEFAULT 1,
+                    auto_on BOOLEAN NOT NULL DEFAULT 1,
+                    auto_off BOOLEAN NOT NULL DEFAULT 1,
+                    off_delay_mode VARCHAR(20) NOT NULL DEFAULT 'time',
+                    off_delay_minutes INTEGER NOT NULL DEFAULT 5,
+                    off_temp_threshold INTEGER NOT NULL DEFAULT 70,
+                    username VARCHAR(50),
+                    password VARCHAR(100),
+                    power_alert_enabled BOOLEAN NOT NULL DEFAULT 0,
+                    power_alert_high FLOAT,
+                    power_alert_low FLOAT,
+                    power_alert_last_triggered DATETIME,
+                    schedule_enabled BOOLEAN NOT NULL DEFAULT 0,
+                    schedule_on_time VARCHAR(5),
+                    schedule_off_time VARCHAR(5),
+                    show_in_switchbar BOOLEAN DEFAULT 0,
+                    last_state VARCHAR(10),
+                    last_checked DATETIME,
+                    auto_off_executed BOOLEAN NOT NULL DEFAULT 0,
+                    auto_off_pending BOOLEAN DEFAULT 0,
+                    auto_off_pending_since DATETIME,
+                    created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
+                    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
+                )
+            """)
+            )
+            # Copy data
+            await conn.execute(
+                text("""
+                INSERT INTO smart_plugs_temp
+                SELECT id, name, ip_address, plug_type, ha_entity_id, ha_power_entity,
+                       ha_energy_today_entity, ha_energy_total_entity, printer_id, enabled,
+                       auto_on, auto_off, off_delay_mode, off_delay_minutes, off_temp_threshold,
+                       username, password, power_alert_enabled, power_alert_high, power_alert_low,
+                       power_alert_last_triggered, schedule_enabled, schedule_on_time, schedule_off_time,
+                       show_in_switchbar, last_state, last_checked, auto_off_executed,
+                       auto_off_pending, auto_off_pending_since, created_at, updated_at
+                FROM smart_plugs
+            """)
+            )
+            # Drop old table and rename new one
+            await conn.execute(text("DROP TABLE smart_plugs"))
+            await conn.execute(text("ALTER TABLE smart_plugs_temp RENAME TO smart_plugs"))
     except Exception:
         pass
 
-    # Migration: Add target_model column to print_queue for model-based assignment
+    # Migration: Add show_on_printer_card column to smart_plugs
     try:
-        await conn.execute(text("ALTER TABLE print_queue ADD COLUMN target_model VARCHAR(50)"))
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN show_on_printer_card BOOLEAN DEFAULT 1"))
     except Exception:
         pass
 
-    # Migration: Add required_filament_types column to print_queue for filament validation
+    # Migration: Add MQTT smart plug fields (legacy)
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_topic VARCHAR(200)"))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_power_path VARCHAR(100)"))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_path VARCHAR(100)"))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_state_path VARCHAR(100)"))
+    except Exception:
+        pass
     try:
-        await conn.execute(text("ALTER TABLE print_queue ADD COLUMN required_filament_types TEXT"))
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_multiplier REAL DEFAULT 1.0"))
     except Exception:
         pass
 
-    # Migration: Add waiting_reason column to print_queue for status feedback
+    # Migration: Add enhanced MQTT smart plug fields (separate topics and multipliers)
     try:
-        await conn.execute(text("ALTER TABLE print_queue ADD COLUMN waiting_reason TEXT"))
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_power_topic VARCHAR(200)"))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_power_multiplier REAL DEFAULT 1.0"))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_topic VARCHAR(200)"))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_multiplier REAL DEFAULT 1.0"))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_state_topic VARCHAR(200)"))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_state_on_value VARCHAR(50)"))
     except Exception:
         pass
 
-    # Migration: Add queue notification event columns to notification_providers
-    queue_notification_columns = [
-        ("on_queue_job_added", "BOOLEAN DEFAULT 0"),
-        ("on_queue_job_assigned", "BOOLEAN DEFAULT 0"),
-        ("on_queue_job_started", "BOOLEAN DEFAULT 0"),
-        ("on_queue_job_waiting", "BOOLEAN DEFAULT 1"),
-        ("on_queue_job_skipped", "BOOLEAN DEFAULT 1"),
-        ("on_queue_job_failed", "BOOLEAN DEFAULT 1"),
-        ("on_queue_completed", "BOOLEAN DEFAULT 0"),
-    ]
-    for col_name, col_def in queue_notification_columns:
-        try:
-            await conn.execute(text(f"ALTER TABLE notification_providers ADD COLUMN {col_name} {col_def}"))
-        except Exception:
-            pass
+    # Migration: Copy existing mqtt_topic to mqtt_power_topic for backward compatibility
+    try:
+        await conn.execute(
+            text("""
+            UPDATE smart_plugs
+            SET mqtt_power_topic = mqtt_topic,
+                mqtt_power_multiplier = mqtt_multiplier
+            WHERE mqtt_topic IS NOT NULL AND mqtt_power_topic IS NULL
+        """)
+        )
+    except Exception:
+        pass
 
     # Migration: Create groups table for permission-based access control
     try:

+ 34 - 1
backend/app/main.py

@@ -249,9 +249,10 @@ _notified_hms_errors: dict[int, set[str]] = {}
 
 
 async def _get_plug_energy(plug, db) -> dict | None:
-    """Get energy from plug regardless of type (Tasmota or Home Assistant).
+    """Get energy from plug regardless of type (Tasmota, Home Assistant, or MQTT).
 
     For HA plugs, configures the service with current settings from DB.
+    For MQTT plugs, returns data from the subscription service.
     """
     if plug.plug_type == "homeassistant":
         from backend.app.api.routes.settings import get_setting
@@ -260,6 +261,17 @@ async def _get_plug_energy(plug, db) -> dict | None:
         ha_token = await get_setting(db, "ha_token") or ""
         homeassistant_service.configure(ha_url, ha_token)
         return await homeassistant_service.get_energy(plug)
+    elif plug.plug_type == "mqtt":
+        # MQTT plugs report "today" energy, not lifetime total
+        # For per-print tracking, we use "today" as the counter (resets at midnight)
+        mqtt_data = mqtt_relay.smart_plug_service.get_plug_data(plug.id)
+        if mqtt_data:
+            return {
+                "power": mqtt_data.power,
+                "today": mqtt_data.energy,
+                "total": mqtt_data.energy,  # Use today as total for per-print calculations
+            }
+        return None
     else:
         return await tasmota_service.get_energy(plug)
 
@@ -2396,6 +2408,27 @@ async def lifespan(app: FastAPI):
         }
         await mqtt_relay.configure(mqtt_settings)
 
+        # Restore MQTT smart plug subscriptions
+        if mqtt_settings.get("mqtt_enabled"):
+            from sqlalchemy import select
+
+            from backend.app.models.smart_plug import SmartPlug
+
+            result = await db.execute(select(SmartPlug).where(SmartPlug.plug_type == "mqtt"))
+            mqtt_plugs = result.scalars().all()
+            for plug in mqtt_plugs:
+                if plug.mqtt_topic:
+                    mqtt_relay.smart_plug_service.subscribe(
+                        plug_id=plug.id,
+                        topic=plug.mqtt_topic,
+                        power_path=plug.mqtt_power_path,
+                        energy_path=plug.mqtt_energy_path,
+                        state_path=plug.mqtt_state_path,
+                        multiplier=plug.mqtt_multiplier or 1.0,
+                    )
+            if mqtt_plugs:
+                logging.info(f"Restored {len(mqtt_plugs)} MQTT smart plug subscriptions")
+
     # Connect to all active printers
     async with async_session() as db:
         await init_printer_connections(db)

+ 31 - 2
backend/app/models/smart_plug.py

@@ -7,7 +7,7 @@ from backend.app.core.database import Base
 
 
 class SmartPlug(Base):
-    """Smart plug for printer power control (Tasmota or Home Assistant)."""
+    """Smart plug for printer power control (Tasmota, Home Assistant, or MQTT)."""
 
     __tablename__ = "smart_plugs"
 
@@ -15,7 +15,7 @@ class SmartPlug(Base):
     name: Mapped[str] = mapped_column(String(100))
     ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)  # IPv4/IPv6 (required for Tasmota)
 
-    # Plug type: "tasmota" (default) or "homeassistant"
+    # Plug type: "tasmota" (default), "homeassistant", or "mqtt"
     plug_type: Mapped[str] = mapped_column(String(20), default="tasmota")
     # Home Assistant entity ID (e.g., "switch.printer_plug")
     ha_entity_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
@@ -24,6 +24,32 @@ class SmartPlug(Base):
     ha_energy_today_entity: Mapped[str | None] = mapped_column(String(100), nullable=True)  # sensor.xxx_today
     ha_energy_total_entity: Mapped[str | None] = mapped_column(String(100), nullable=True)  # sensor.xxx_total
 
+    # MQTT plug fields (required when plug_type="mqtt")
+    # Legacy field - kept for backward compatibility, now use mqtt_power_topic
+    mqtt_topic: Mapped[str | None] = mapped_column(
+        String(200), nullable=True
+    )  # e.g., "zigbee2mqtt/shelly-working-room" (deprecated, use mqtt_power_topic)
+
+    # Power monitoring
+    mqtt_power_topic: Mapped[str | None] = mapped_column(String(200), nullable=True)  # Topic for power data
+    mqtt_power_path: Mapped[str | None] = mapped_column(String(100), nullable=True)  # e.g., "power_l1" or "data.power"
+    mqtt_power_multiplier: Mapped[float] = mapped_column(Float, default=1.0)  # Unit conversion for power
+
+    # Energy monitoring
+    mqtt_energy_topic: Mapped[str | None] = mapped_column(String(200), nullable=True)  # Topic for energy data
+    mqtt_energy_path: Mapped[str | None] = mapped_column(String(100), nullable=True)  # e.g., "energy_l1"
+    mqtt_energy_multiplier: Mapped[float] = mapped_column(Float, default=1.0)  # Unit conversion for energy
+
+    # State monitoring
+    mqtt_state_topic: Mapped[str | None] = mapped_column(String(200), nullable=True)  # Topic for state data
+    mqtt_state_path: Mapped[str | None] = mapped_column(String(100), nullable=True)  # e.g., "state_l1" for ON/OFF
+    mqtt_state_on_value: Mapped[str | None] = mapped_column(
+        String(50), nullable=True
+    )  # What value means "ON" (e.g., "ON", "true", "1")
+
+    # Legacy multiplier - kept for backward compatibility
+    mqtt_multiplier: Mapped[float] = mapped_column(Float, default=1.0)  # Deprecated, use mqtt_power_multiplier
+
     # Link to printer (1:1)
     printer_id: Mapped[int | None] = mapped_column(
         ForeignKey("printers.id", ondelete="SET NULL"), unique=True, nullable=True
@@ -57,6 +83,9 @@ class SmartPlug(Base):
     # Switchbar visibility
     show_in_switchbar: Mapped[bool] = mapped_column(Boolean, default=False)
 
+    # Printer card visibility (for scripts)
+    show_on_printer_card: Mapped[bool] = mapped_column(Boolean, default=True)
+
     # Status tracking
     last_state: Mapped[str | None] = mapped_column(String(10), nullable=True)  # "ON"/"OFF"
     last_checked: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)

+ 58 - 6
backend/app/schemas/smart_plug.py

@@ -6,7 +6,7 @@ from pydantic import BaseModel, Field, model_validator
 
 class SmartPlugBase(BaseModel):
     name: str = Field(..., min_length=1, max_length=100)
-    plug_type: Literal["tasmota", "homeassistant"] = "tasmota"
+    plug_type: Literal["tasmota", "homeassistant", "mqtt"] = "tasmota"
 
     # Tasmota fields (required when plug_type="tasmota")
     ip_address: str | None = Field(default=None, pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
@@ -14,12 +14,36 @@ class SmartPlugBase(BaseModel):
     password: str | None = None
 
     # Home Assistant fields (required when plug_type="homeassistant")
-    ha_entity_id: str | None = Field(default=None, pattern=r"^(switch|light|input_boolean)\.[a-z0-9_]+$")
+    ha_entity_id: str | None = Field(default=None, pattern=r"^(switch|light|input_boolean|script)\.[a-z0-9_]+$")
     # Home Assistant energy sensor entities (optional, for separate energy sensors)
     ha_power_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$")
     ha_energy_today_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$")
     ha_energy_total_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$")
 
+    # MQTT fields (required when plug_type="mqtt")
+    # Legacy field - kept for backward compatibility
+    mqtt_topic: str | None = Field(default=None, max_length=200)  # Deprecated, use mqtt_power_topic
+
+    # Power monitoring
+    mqtt_power_topic: str | None = Field(default=None, max_length=200)  # Topic for power data
+    mqtt_power_path: str | None = Field(default=None, max_length=100)  # e.g., "power_l1" or "data.power"
+    mqtt_power_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)  # Unit conversion for power
+
+    # Energy monitoring
+    mqtt_energy_topic: str | None = Field(default=None, max_length=200)  # Topic for energy data
+    mqtt_energy_path: str | None = Field(default=None, max_length=100)  # e.g., "energy_l1"
+    mqtt_energy_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)  # Unit conversion for energy
+
+    # State monitoring
+    mqtt_state_topic: str | None = Field(default=None, max_length=200)  # Topic for state data
+    mqtt_state_path: str | None = Field(default=None, max_length=100)  # e.g., "state_l1" for ON/OFF
+    mqtt_state_on_value: str | None = Field(
+        default=None, max_length=50
+    )  # What value means "ON" (e.g., "ON", "true", "1")
+
+    # Legacy multiplier - kept for backward compatibility
+    mqtt_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)  # Deprecated, use mqtt_power_multiplier
+
     printer_id: int | None = None
     enabled: bool = True
     auto_on: bool = True
@@ -35,8 +59,9 @@ class SmartPlugBase(BaseModel):
     schedule_enabled: bool = False
     schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")  # HH:MM format
     schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")  # HH:MM format
-    # Switchbar visibility
+    # Visibility options
     show_in_switchbar: bool = False
+    show_on_printer_card: bool = True  # For scripts: show on printer card
 
     @model_validator(mode="after")
     def validate_plug_type_fields(self) -> "SmartPlugBase":
@@ -44,6 +69,17 @@ class SmartPlugBase(BaseModel):
             raise ValueError("ip_address is required for Tasmota plugs")
         if self.plug_type == "homeassistant" and not self.ha_entity_id:
             raise ValueError("ha_entity_id is required for Home Assistant plugs")
+        if self.plug_type == "mqtt":
+            # Determine the effective power topic (new field takes priority, fall back to legacy)
+            power_topic = self.mqtt_power_topic or self.mqtt_topic
+            # Path is optional - if not set, raw MQTT payload value will be used
+            has_power = bool(power_topic)
+            has_energy = bool(self.mqtt_energy_topic)
+            has_state = bool(self.mqtt_state_topic)
+
+            # At least one data source must be configured (path is optional)
+            if not has_power and not has_energy and not has_state:
+                raise ValueError("At least one MQTT topic must be configured for power, energy, or state monitoring")
         return self
 
 
@@ -53,13 +89,28 @@ class SmartPlugCreate(SmartPlugBase):
 
 class SmartPlugUpdate(BaseModel):
     name: str | None = None
-    plug_type: Literal["tasmota", "homeassistant"] | None = None
+    plug_type: Literal["tasmota", "homeassistant", "mqtt"] | None = None
     ip_address: str | None = None
     ha_entity_id: str | None = None
     # Home Assistant energy sensor entities (optional)
     ha_power_entity: str | None = None
     ha_energy_today_entity: str | None = None
     ha_energy_total_entity: str | None = None
+    # MQTT fields (legacy)
+    mqtt_topic: str | None = None
+    mqtt_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
+    # MQTT power fields
+    mqtt_power_topic: str | None = None
+    mqtt_power_path: str | None = None
+    mqtt_power_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
+    # MQTT energy fields
+    mqtt_energy_topic: str | None = None
+    mqtt_energy_path: str | None = None
+    mqtt_energy_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
+    # MQTT state fields
+    mqtt_state_topic: str | None = None
+    mqtt_state_path: str | None = None
+    mqtt_state_on_value: str | None = None
     printer_id: int | None = None
     enabled: bool | None = None
     auto_on: bool | None = None
@@ -77,8 +128,9 @@ class SmartPlugUpdate(BaseModel):
     schedule_enabled: bool | None = None
     schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
     schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
-    # Switchbar visibility
+    # Visibility options
     show_in_switchbar: bool | None = None
+    show_on_printer_card: bool | None = None
 
 
 class SmartPlugResponse(SmartPlugBase):
@@ -147,7 +199,7 @@ class HAEntity(BaseModel):
     entity_id: str
     friendly_name: str
     state: str | None = None
-    domain: str  # "switch", "light", "input_boolean"
+    domain: str  # "switch", "light", "input_boolean", "script"
 
 
 class HASensorEntity(BaseModel):

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

@@ -228,7 +228,7 @@ class HomeAssistantService:
             - domain: str
         """
         # Default domains for smart plug control
-        default_domains = {"switch", "light", "input_boolean"}
+        default_domains = {"switch", "light", "input_boolean", "script"}
 
         try:
             async with httpx.AsyncClient(timeout=self.timeout) as client:

+ 32 - 1
backend/app/services/mqtt_relay.py

@@ -34,6 +34,8 @@ class MQTTRelayService:
         self._broker = ""
         self._port = 1883
         self._last_printer_status: dict[int, float] = {}  # printer_id -> last publish timestamp
+        self._smart_plug_service = None  # Lazy import to avoid circular dependency
+        self._settings: dict = {}  # Store settings for smart plug service
 
     async def configure(self, settings: dict) -> bool:
         """Configure MQTT connection from settings.
@@ -41,9 +43,12 @@ class MQTTRelayService:
         Returns True if connection was successful or MQTT is disabled.
         """
         self.enabled = settings.get("mqtt_enabled", False)
+        self._settings = settings  # Store for smart plug service
 
         if not self.enabled:
             await self.disconnect()
+            # Also configure smart plug service (will disable it)
+            await self._configure_smart_plug_service(settings)
             logger.info("MQTT relay disabled")
             return True
 
@@ -67,7 +72,33 @@ class MQTTRelayService:
             await self.disconnect()
 
         # Create and connect client
-        return await self._connect(broker, port, username, password, use_tls)
+        result = await self._connect(broker, port, username, password, use_tls)
+
+        # Configure smart plug service with same settings
+        await self._configure_smart_plug_service(settings)
+
+        return result
+
+    async def _configure_smart_plug_service(self, settings: dict):
+        """Configure the MQTT smart plug service with the same broker settings."""
+        try:
+            if self._smart_plug_service is None:
+                from backend.app.services.mqtt_smart_plug import mqtt_smart_plug_service
+
+                self._smart_plug_service = mqtt_smart_plug_service
+
+            await self._smart_plug_service.configure(settings)
+        except Exception as e:
+            logger.error(f"Failed to configure MQTT smart plug service: {e}")
+
+    @property
+    def smart_plug_service(self):
+        """Get the MQTT smart plug service instance."""
+        if self._smart_plug_service is None:
+            from backend.app.services.mqtt_smart_plug import mqtt_smart_plug_service
+
+            self._smart_plug_service = mqtt_smart_plug_service
+        return self._smart_plug_service
 
     async def _connect(self, broker: str, port: int, username: str, password: str, use_tls: bool) -> bool:
         """Establish MQTT connection."""

+ 488 - 0
backend/app/services/mqtt_smart_plug.py

@@ -0,0 +1,488 @@
+"""MQTT Smart Plug Service for subscribing to external MQTT topics and extracting power/energy data.
+
+This service enables integration with Shelly, Zigbee2MQTT, and other MQTT-based energy monitoring devices.
+"""
+
+import json
+import logging
+import threading
+from dataclasses import dataclass, field
+from datetime import datetime, timedelta
+from typing import Any
+
+import paho.mqtt.client as mqtt
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class SmartPlugMQTTData:
+    """Latest data received from an MQTT smart plug."""
+
+    plug_id: int
+    power: float | None = None  # Current power in watts
+    energy: float | None = None  # Energy in kWh (today)
+    state: str | None = None  # "ON" or "OFF"
+    last_seen: datetime = field(default_factory=datetime.utcnow)
+
+
+@dataclass
+class MQTTDataSourceConfig:
+    """Configuration for a single MQTT data source (power, energy, or state)."""
+
+    topic: str
+    path: str
+    multiplier: float = 1.0  # For power/energy
+    on_value: str | None = None  # For state (what value means "ON")
+
+
+class MQTTSmartPlugService:
+    """Subscribes to MQTT topics for smart plug energy monitoring."""
+
+    # Consider plug unreachable if no message received in this time
+    REACHABLE_TIMEOUT_MINUTES = 5
+
+    def __init__(self):
+        self.client: mqtt.Client | None = None
+        self.connected = False
+        self._lock = threading.Lock()
+        # topic -> list of (plug_id, data_type) where data_type is "power", "energy", or "state"
+        self.subscriptions: dict[str, list[tuple[int, str]]] = {}
+        # plug_id -> {data_type: MQTTDataSourceConfig}
+        self.plug_configs: dict[int, dict[str, MQTTDataSourceConfig]] = {}
+        # plug_id -> latest data
+        self.plug_data: dict[int, SmartPlugMQTTData] = {}
+        self._configured = False
+        self._broker = ""
+        self._port = 1883
+        self._username = ""
+        self._password = ""
+        self._use_tls = False
+
+    def is_configured(self) -> bool:
+        """Check if the MQTT service is configured and connected."""
+        return self._configured and self.connected
+
+    def has_broker_settings(self) -> bool:
+        """Check if broker settings are available (even if not connected yet)."""
+        return bool(self._broker)
+
+    async def configure(self, settings: dict) -> bool:
+        """Configure MQTT connection from settings.
+
+        Uses the same broker settings as the MQTT relay service.
+        Returns True if connection was successful or MQTT is disabled.
+        """
+        enabled = settings.get("mqtt_enabled", False)
+
+        if not enabled:
+            await self.disconnect()
+            self._configured = False
+            logger.debug("MQTT smart plug service disabled (MQTT relay not enabled)")
+            return True
+
+        broker = settings.get("mqtt_broker", "")
+        port = settings.get("mqtt_port", 1883)
+        username = settings.get("mqtt_username", "")
+        password = settings.get("mqtt_password", "")
+        use_tls = settings.get("mqtt_use_tls", False)
+
+        if not broker:
+            logger.warning("MQTT smart plug service: no broker configured")
+            self._configured = False
+            return False
+
+        # Check if settings changed
+        settings_changed = (
+            self._broker != broker
+            or self._port != port
+            or self._username != username
+            or self._password != password
+            or self._use_tls != use_tls
+        )
+
+        self._broker = broker
+        self._port = port
+        self._username = username
+        self._password = password
+        self._use_tls = use_tls
+        self._configured = True
+
+        # Disconnect and reconnect if settings changed
+        if settings_changed and self.client:
+            await self.disconnect()
+
+        # Connect if not already connected
+        if not self.client or not self.connected:
+            return await self._connect()
+
+        return True
+
+    async def _connect(self) -> bool:
+        """Establish MQTT connection."""
+        import asyncio
+        import ssl
+
+        try:
+            # Create client with callback API version 2
+            self.client = mqtt.Client(
+                callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
+                client_id=f"bambuddy-smartplug-{id(self)}",
+                protocol=mqtt.MQTTv311,
+            )
+
+            # Set up callbacks
+            self.client.on_connect = self._on_connect
+            self.client.on_disconnect = self._on_disconnect
+            self.client.on_message = self._on_message
+
+            # Configure authentication
+            if self._username:
+                self.client.username_pw_set(self._username, self._password)
+
+            # Configure TLS
+            if self._use_tls:
+                self.client.tls_set(cert_reqs=ssl.CERT_NONE)
+                self.client.tls_insecure_set(True)
+
+            # Connect with timeout
+            try:
+                await asyncio.wait_for(
+                    asyncio.to_thread(self.client.connect_async, self._broker, self._port, 60),
+                    timeout=3.0,
+                )
+            except TimeoutError:
+                logger.warning(f"MQTT smart plug connection to {self._broker}:{self._port} timed out")
+                return False
+
+            self.client.loop_start()
+
+            # Wait briefly for connection
+            await asyncio.sleep(1.0)
+
+            if self.connected:
+                logger.info(f"MQTT smart plug service connected to {self._broker}:{self._port}")
+                # Resubscribe to all topics
+                self._resubscribe_all()
+                return True
+            else:
+                logger.warning(f"MQTT smart plug connection pending to {self._broker}:{self._port}")
+                return True  # Connection is async
+
+        except Exception as e:
+            logger.error(f"MQTT smart plug connection failed: {e}")
+            self.connected = False
+            return False
+
+    def _on_connect(
+        self,
+        client: mqtt.Client,
+        userdata: Any,
+        flags: dict,
+        reason_code: int | mqtt.ReasonCode,
+        properties: mqtt.Properties | None = None,
+    ):
+        """Callback when connected to broker."""
+        rc = reason_code if isinstance(reason_code, int) else reason_code.value
+        if rc == 0:
+            self.connected = True
+            logger.info("MQTT smart plug service connected successfully")
+            # Resubscribe to all topics
+            self._resubscribe_all()
+        else:
+            self.connected = False
+            logger.error(f"MQTT smart plug connection failed: {reason_code}")
+
+    def _on_disconnect(
+        self,
+        client: mqtt.Client,
+        userdata: Any,
+        flags_or_rc: dict | int | mqtt.ReasonCode,
+        reason_code: int | mqtt.ReasonCode | None = None,
+        properties: mqtt.Properties | None = None,
+    ):
+        """Callback when disconnected from broker."""
+        self.connected = False
+        rc = reason_code if reason_code is not None else flags_or_rc
+        rc_val = rc if isinstance(rc, int) else getattr(rc, "value", 0)
+        if rc_val != 0:
+            logger.warning(f"MQTT smart plug service disconnected: {rc}")
+        else:
+            logger.info("MQTT smart plug service disconnected cleanly")
+
+    def _on_message(self, client: mqtt.Client, userdata: Any, msg: mqtt.MQTTMessage):
+        """Handle incoming MQTT message, extract data using JSON path."""
+        topic = msg.topic
+
+        with self._lock:
+            subscriptions = self.subscriptions.get(topic, [])
+            if not subscriptions:
+                return
+
+            # Parse JSON payload (or treat as raw value)
+            try:
+                payload = json.loads(msg.payload.decode("utf-8"))
+                is_json = True
+            except (json.JSONDecodeError, UnicodeDecodeError):
+                # Not JSON - treat the whole payload as a raw value
+                payload = msg.payload.decode("utf-8").strip()
+                is_json = False
+
+            # Process for each subscribed (plug_id, data_type)
+            for plug_id, data_type in subscriptions:
+                configs = self.plug_configs.get(plug_id, {})
+                config = configs.get(data_type)
+                if not config:
+                    continue
+
+                # Extract value using path (or use raw payload if no path)
+                if is_json and config.path:
+                    raw_value = self._extract_json_path(payload, config.path)
+                elif is_json and not config.path:
+                    # JSON but no path - if it's a simple value use it, otherwise skip
+                    if isinstance(payload, (int, float, str, bool)):
+                        raw_value = payload
+                    else:
+                        # Can't use a dict/list as a value
+                        logger.debug(f"MQTT plug {plug_id}: JSON payload is object/array but no path configured")
+                        continue
+                else:
+                    # Raw value (non-JSON)
+                    raw_value = payload
+
+                if raw_value is None:
+                    continue
+
+                # Initialize plug data if needed
+                if plug_id not in self.plug_data:
+                    self.plug_data[plug_id] = SmartPlugMQTTData(plug_id=plug_id)
+
+                data = self.plug_data[plug_id]
+                data.last_seen = datetime.utcnow()
+
+                # Process based on data type
+                if data_type == "power":
+                    try:
+                        data.power = float(raw_value) * config.multiplier
+                        logger.debug(f"MQTT smart plug {plug_id}: power={data.power}")
+                    except (ValueError, TypeError):
+                        pass
+
+                elif data_type == "energy":
+                    try:
+                        data.energy = float(raw_value) * config.multiplier
+                        logger.debug(f"MQTT smart plug {plug_id}: energy={data.energy}")
+                    except (ValueError, TypeError):
+                        pass
+
+                elif data_type == "state":
+                    state_str = str(raw_value)
+                    # Check against configured ON value if set
+                    if config.on_value:
+                        # Case-insensitive comparison
+                        if state_str.lower() == config.on_value.lower():
+                            data.state = "ON"
+                        else:
+                            data.state = "OFF"
+                    else:
+                        # Default behavior: normalize common values
+                        upper_state = state_str.upper()
+                        if upper_state in ("ON", "1", "TRUE"):
+                            data.state = "ON"
+                        elif upper_state in ("OFF", "0", "FALSE"):
+                            data.state = "OFF"
+                        else:
+                            data.state = state_str
+                    logger.debug(f"MQTT smart plug {plug_id}: state={data.state}")
+
+    def _extract_json_path(self, data: dict, path: str) -> Any:
+        """Extract value using dot notation (e.g., 'power_l1' or 'data.power').
+
+        Supports simple dot notation for nested objects.
+        """
+        if not path:
+            return None
+
+        parts = path.split(".")
+        current = data
+
+        for part in parts:
+            if isinstance(current, dict) and part in current:
+                current = current[part]
+            else:
+                return None
+
+        return current
+
+    def _resubscribe_all(self):
+        """Resubscribe to all registered topics after reconnection."""
+        if not self.client or not self.connected:
+            return
+
+        with self._lock:
+            for topic in self.subscriptions:
+                if self.subscriptions[topic]:  # Only if there are subscribers
+                    try:
+                        self.client.subscribe(topic, qos=1)
+                        logger.debug(f"MQTT smart plug: resubscribed to {topic}")
+                    except Exception as e:
+                        logger.error(f"MQTT smart plug: failed to resubscribe to {topic}: {e}")
+
+    def subscribe(
+        self,
+        plug_id: int,
+        # Power source
+        power_topic: str | None = None,
+        power_path: str | None = None,
+        power_multiplier: float = 1.0,
+        # Energy source
+        energy_topic: str | None = None,
+        energy_path: str | None = None,
+        energy_multiplier: float = 1.0,
+        # State source
+        state_topic: str | None = None,
+        state_path: str | None = None,
+        state_on_value: str | None = None,
+        # Legacy: single topic/path/multiplier (for backward compatibility)
+        topic: str | None = None,
+        multiplier: float = 1.0,
+    ):
+        """Subscribe to MQTT topics for a plug.
+
+        Each data type (power, energy, state) can have its own topic.
+        For backward compatibility, if power_topic is not set but topic is,
+        topic will be used for all data types that have paths configured.
+        """
+        with self._lock:
+            # Initialize config for this plug
+            self.plug_configs[plug_id] = {}
+
+            # Determine topics (new fields take priority, fall back to legacy)
+            effective_power_topic = power_topic or topic
+            effective_energy_topic = energy_topic or topic
+            effective_state_topic = state_topic or topic
+
+            # Use new multipliers or fall back to legacy
+            effective_power_mult = power_multiplier if power_multiplier != 1.0 else multiplier
+            effective_energy_mult = energy_multiplier if energy_multiplier != 1.0 else multiplier
+
+            # Configure power subscription (path is optional - empty means use raw payload)
+            if effective_power_topic:
+                config = MQTTDataSourceConfig(
+                    topic=effective_power_topic,
+                    path=power_path or "",
+                    multiplier=effective_power_mult,
+                )
+                self.plug_configs[plug_id]["power"] = config
+                self._add_subscription(plug_id, effective_power_topic, "power")
+
+            # Configure energy subscription (path is optional - empty means use raw payload)
+            if effective_energy_topic:
+                config = MQTTDataSourceConfig(
+                    topic=effective_energy_topic,
+                    path=energy_path or "",
+                    multiplier=effective_energy_mult,
+                )
+                self.plug_configs[plug_id]["energy"] = config
+                self._add_subscription(plug_id, effective_energy_topic, "energy")
+
+            # Configure state subscription (path is optional - empty means use raw payload)
+            if effective_state_topic:
+                config = MQTTDataSourceConfig(
+                    topic=effective_state_topic,
+                    path=state_path or "",
+                    on_value=state_on_value,
+                )
+                self.plug_configs[plug_id]["state"] = config
+                self._add_subscription(plug_id, effective_state_topic, "state")
+
+            # Initialize data entry
+            if plug_id not in self.plug_data:
+                self.plug_data[plug_id] = SmartPlugMQTTData(plug_id=plug_id)
+
+            logger.info(
+                f"MQTT smart plug {plug_id}: configured with "
+                f"power={effective_power_topic if power_path else None}, "
+                f"energy={effective_energy_topic if energy_path else None}, "
+                f"state={effective_state_topic if state_path else None}"
+            )
+
+    def _add_subscription(self, plug_id: int, topic: str, data_type: str):
+        """Add a subscription for a plug/data_type to a topic."""
+        if topic not in self.subscriptions:
+            self.subscriptions[topic] = []
+            # Actually subscribe if connected
+            if self.client and self.connected:
+                try:
+                    self.client.subscribe(topic, qos=1)
+                    logger.info(f"MQTT smart plug: subscribed to {topic}")
+                except Exception as e:
+                    logger.error(f"MQTT smart plug: failed to subscribe to {topic}: {e}")
+
+        entry = (plug_id, data_type)
+        if entry not in self.subscriptions[topic]:
+            self.subscriptions[topic].append(entry)
+
+    def unsubscribe(self, plug_id: int):
+        """Unsubscribe when plug is deleted/updated."""
+        with self._lock:
+            # Get all configs for this plug
+            configs = self.plug_configs.pop(plug_id, {})
+            if not configs:
+                # Still clean up any stray subscriptions
+                pass
+
+            # Collect all topics this plug was subscribed to
+            topics_to_check = set()
+            for _data_type, config in configs.items():
+                topics_to_check.add(config.topic)
+
+            # Also scan subscriptions to remove any entries for this plug
+            for topic in list(self.subscriptions.keys()):
+                # Remove all entries for this plug_id
+                self.subscriptions[topic] = [(pid, dtype) for pid, dtype in self.subscriptions[topic] if pid != plug_id]
+                topics_to_check.add(topic)
+
+            # Unsubscribe from topics with no more subscribers
+            for topic in topics_to_check:
+                if topic in self.subscriptions and not self.subscriptions[topic]:
+                    del self.subscriptions[topic]
+                    if self.client and self.connected:
+                        try:
+                            self.client.unsubscribe(topic)
+                            logger.info(f"MQTT smart plug: unsubscribed from {topic}")
+                        except Exception as e:
+                            logger.error(f"MQTT smart plug: failed to unsubscribe from {topic}: {e}")
+
+            # Remove data
+            self.plug_data.pop(plug_id, None)
+
+    def get_plug_data(self, plug_id: int) -> SmartPlugMQTTData | None:
+        """Get latest data for a plug (called by status endpoint)."""
+        with self._lock:
+            return self.plug_data.get(plug_id)
+
+    def is_reachable(self, plug_id: int) -> bool:
+        """Check if a plug has received data recently."""
+        data = self.get_plug_data(plug_id)
+        if not data:
+            return False
+
+        timeout = timedelta(minutes=self.REACHABLE_TIMEOUT_MINUTES)
+        return datetime.utcnow() - data.last_seen < timeout
+
+    async def disconnect(self):
+        """Disconnect from MQTT broker."""
+        if self.client:
+            try:
+                self.client.loop_stop()
+                self.client.disconnect()
+            except Exception as e:
+                logger.debug(f"MQTT smart plug disconnect error (ignored): {e}")
+            finally:
+                self.client = None
+                self.connected = False
+
+
+# Global instance
+mqtt_smart_plug_service = MQTTSmartPlugService()

+ 8 - 4
backend/app/services/print_scheduler.py

@@ -17,7 +17,7 @@ from backend.app.models.smart_plug import SmartPlug
 from backend.app.services.bambu_ftp import delete_file_async, get_ftp_retry_settings, upload_file_async, with_ftp_retry
 from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import printer_manager
-from backend.app.services.tasmota import tasmota_service
+from backend.app.services.smart_plug_manager import smart_plug_manager
 from backend.app.utils.printer_models import normalize_printer_model
 
 logger = logging.getLogger(__name__)
@@ -348,15 +348,18 @@ class PrintScheduler:
 
         Returns True if printer connected successfully within timeout.
         """
+        # Get the appropriate service for the plug type (Tasmota or Home Assistant)
+        service = await smart_plug_manager.get_service_for_plug(plug, db)
+
         # Check current plug state
-        status = await tasmota_service.get_status(plug)
+        status = await service.get_status(plug)
         if not status.get("reachable"):
             logger.warning(f"Smart plug '{plug.name}' is not reachable")
             return False
 
         # Turn on if not already on
         if status.get("state") != "ON":
-            success = await tasmota_service.turn_on(plug)
+            success = await service.turn_on(plug)
             if not success:
                 logger.warning(f"Failed to turn on smart plug '{plug.name}'")
                 return False
@@ -425,7 +428,8 @@ class PrintScheduler:
             # Wait for cooldown (up to 10 minutes)
             await printer_manager.wait_for_cooldown(item.printer_id, target_temp=50.0, timeout=600)
             logger.info(f"Auto-off: Powering off printer {item.printer_id}")
-            await tasmota_service.turn_off(plug)
+            service = await smart_plug_manager.get_service_for_plug(plug, db)
+            await service.turn_off(plug)
 
     async def _get_job_name(self, db: AsyncSession, item: PrintQueueItem) -> str:
         """Get a human-readable name for a queue item."""

+ 11 - 6
backend/app/services/smart_plug_manager.py

@@ -27,7 +27,7 @@ class SmartPlugManager:
         self._scheduler_task: asyncio.Task | None = None
         self._last_schedule_check: dict[int, str] = {}  # plug_id -> "HH:MM" last executed
 
-    async def _get_service_for_plug(self, plug: "SmartPlug", db: AsyncSession | None = None):
+    async def get_service_for_plug(self, plug: "SmartPlug", db: AsyncSession | None = None):
         """Get the appropriate service for the plug type.
 
         For HA plugs, configures the service with current settings from DB.
@@ -110,7 +110,7 @@ class SmartPlugManager:
             plugs = result.scalars().all()
 
             for plug in plugs:
-                service = await self._get_service_for_plug(plug, db)
+                service = await self.get_service_for_plug(plug, db)
 
                 # Check if we should turn on
                 if plug.schedule_on_time == current_time:
@@ -166,7 +166,7 @@ class SmartPlugManager:
 
         # Turn on the plug
         logger.info(f"Print started on printer {printer_id}, turning on plug '{plug.name}'")
-        service = await self._get_service_for_plug(plug, db)
+        service = await self.get_service_for_plug(plug, db)
         success = await service.turn_on(plug)
 
         if success:
@@ -195,6 +195,11 @@ class SmartPlugManager:
             logger.debug(f"Smart plug '{plug.name}' auto_off is disabled")
             return
 
+        # Skip auto-off for HA script entities (scripts can only be triggered, not turned off)
+        if plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."):
+            logger.debug(f"Smart plug '{plug.name}' is a HA script entity, skipping auto-off")
+            return
+
         # Only auto-off on successful completion, not on failures
         # This allows the user to investigate errors before power-off
         if status != "completed":
@@ -261,7 +266,7 @@ class SmartPlugManager:
                     self.name = f"plug_{plug_id}"
 
             plug_info = PlugInfo()
-            service = await self._get_service_for_plug(plug_info)
+            service = await self.get_service_for_plug(plug_info)
             success = await service.turn_off(plug_info)
             logger.info(f"Turned off plug {plug_id} after time delay")
 
@@ -353,7 +358,7 @@ class SmartPlugManager:
                                 self.name = f"plug_{plug_id}"
 
                         plug_info = PlugInfo()
-                        service = await self._get_service_for_plug(plug_info)
+                        service = await self.get_service_for_plug(plug_info)
                         success = await service.turn_off(plug_info)
                         logger.info(
                             f"Turned off plug {plug_id} after nozzle temp dropped to "
@@ -474,7 +479,7 @@ class SmartPlugManager:
                         # For time mode, just turn off immediately since delay already passed
                         logger.info(f"Time-based auto-off was pending, turning off plug '{plug.name}' now")
 
-                        service = await self._get_service_for_plug(plug, db)
+                        service = await self.get_service_for_plug(plug, db)
                         success = await service.turn_off(plug)
                         if success:
                             await self._mark_auto_off_executed(plug.id)

+ 34 - 0
backend/tests/conftest.py

@@ -222,6 +222,24 @@ def mock_mqtt_client():
         yield mock
 
 
+@pytest.fixture
+def mock_mqtt_smart_plug_service():
+    """Mock the MQTT smart plug service for MQTT plug tests."""
+    with patch("backend.app.api.routes.smart_plugs.mqtt_relay") as mock:
+        # Create a mock smart_plug_service
+        mock_service = MagicMock()
+        mock_service.is_configured = MagicMock(return_value=True)
+        mock_service.has_broker_settings = MagicMock(return_value=True)
+        mock_service.configure = AsyncMock(return_value=True)
+        mock_service.subscribe = MagicMock()
+        mock_service.unsubscribe = MagicMock()
+        mock_service.get_plug_data = MagicMock(return_value=None)
+        mock_service.is_reachable = MagicMock(return_value=False)
+
+        mock.smart_plug_service = mock_service
+        yield mock
+
+
 @pytest.fixture
 def mock_ftp_client():
     """Mock the FTP client for file transfer tests."""
@@ -302,6 +320,22 @@ def smart_plug_factory(db_session):
         if plug_type == "homeassistant":
             defaults["ha_entity_id"] = "switch.test"
             defaults["ip_address"] = None
+        elif plug_type == "mqtt":
+            # Legacy fields (for backward compatibility tests)
+            defaults["mqtt_topic"] = kwargs.get("mqtt_topic", "test/topic")
+            defaults["mqtt_multiplier"] = kwargs.get("mqtt_multiplier", 1.0)
+            # New separate topic/path/multiplier fields
+            defaults["mqtt_power_topic"] = kwargs.get("mqtt_power_topic")
+            defaults["mqtt_power_path"] = kwargs.get("mqtt_power_path", "power")
+            defaults["mqtt_power_multiplier"] = kwargs.get("mqtt_power_multiplier", 1.0)
+            defaults["mqtt_energy_topic"] = kwargs.get("mqtt_energy_topic")
+            defaults["mqtt_energy_path"] = kwargs.get("mqtt_energy_path")
+            defaults["mqtt_energy_multiplier"] = kwargs.get("mqtt_energy_multiplier", 1.0)
+            defaults["mqtt_state_topic"] = kwargs.get("mqtt_state_topic")
+            defaults["mqtt_state_path"] = kwargs.get("mqtt_state_path")
+            defaults["mqtt_state_on_value"] = kwargs.get("mqtt_state_on_value")
+            defaults["ip_address"] = None
+            defaults["ha_entity_id"] = None
         else:
             defaults["ip_address"] = "192.168.1.100"
             defaults["ha_entity_id"] = None

+ 124 - 0
backend/tests/integration/test_archives_api.py

@@ -434,3 +434,127 @@ class TestArchiveF3DEndpoints:
         """Verify filament-requirements with plate_id returns 404 for non-existent archive."""
         response = await async_client.get("/api/v1/archives/999999/filament-requirements?plate_id=1")
         assert response.status_code == 404
+
+    # ========================================================================
+    # Tag Management endpoints (Issue #183)
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_tags_empty(self, async_client: AsyncClient):
+        """Verify empty list when no tags exist."""
+        response = await async_client.get("/api/v1/archives/tags")
+        assert response.status_code == 200
+        data = response.json()
+        assert isinstance(data, list)
+        assert len(data) == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_tags_with_data(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
+        """Verify tags are returned with counts."""
+        printer = await printer_factory()
+        await archive_factory(printer.id, print_name="Archive 1", tags="functional, test")
+        await archive_factory(printer.id, print_name="Archive 2", tags="functional, calibration")
+        await archive_factory(printer.id, print_name="Archive 3", tags="test")
+
+        response = await async_client.get("/api/v1/archives/tags")
+        assert response.status_code == 200
+        data = response.json()
+        assert isinstance(data, list)
+
+        # Convert to dict for easier lookup
+        tags_dict = {t["name"]: t["count"] for t in data}
+        assert tags_dict.get("functional") == 2
+        assert tags_dict.get("test") == 2
+        assert tags_dict.get("calibration") == 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_tags_sorted_by_count(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Verify tags are sorted by count descending, then by name."""
+        printer = await printer_factory()
+        await archive_factory(printer.id, tags="alpha")
+        await archive_factory(printer.id, tags="beta, alpha")
+        await archive_factory(printer.id, tags="gamma, beta, alpha")
+
+        response = await async_client.get("/api/v1/archives/tags")
+        assert response.status_code == 200
+        data = response.json()
+
+        # alpha=3, beta=2, gamma=1
+        assert data[0]["name"] == "alpha"
+        assert data[0]["count"] == 3
+        assert data[1]["name"] == "beta"
+        assert data[1]["count"] == 2
+        assert data[2]["name"] == "gamma"
+        assert data[2]["count"] == 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rename_tag(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
+        """Verify renaming a tag updates all archives."""
+        printer = await printer_factory()
+        a1 = await archive_factory(printer.id, print_name="Archive 1", tags="old-tag, other")
+        a2 = await archive_factory(printer.id, print_name="Archive 2", tags="old-tag")
+        await archive_factory(printer.id, print_name="Archive 3", tags="different")
+
+        response = await async_client.put("/api/v1/archives/tags/old-tag", json={"new_name": "new-tag"})
+        assert response.status_code == 200
+        data = response.json()
+        assert data["affected"] == 2
+
+        # Verify the archives were updated
+        response = await async_client.get(f"/api/v1/archives/{a1.id}")
+        assert "new-tag" in response.json()["tags"]
+        assert "old-tag" not in response.json()["tags"]
+
+        response = await async_client.get(f"/api/v1/archives/{a2.id}")
+        assert response.json()["tags"] == "new-tag"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rename_tag_no_change(self, async_client: AsyncClient):
+        """Verify renaming to same name returns 0 affected."""
+        response = await async_client.put("/api/v1/archives/tags/some-tag", json={"new_name": "some-tag"})
+        assert response.status_code == 200
+        assert response.json()["affected"] == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rename_tag_empty_name_error(self, async_client: AsyncClient):
+        """Verify renaming to empty name returns error."""
+        response = await async_client.put("/api/v1/archives/tags/some-tag", json={"new_name": ""})
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_tag(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
+        """Verify deleting a tag removes it from all archives."""
+        printer = await printer_factory()
+        a1 = await archive_factory(printer.id, print_name="Archive 1", tags="delete-me, keep")
+        a2 = await archive_factory(printer.id, print_name="Archive 2", tags="delete-me")
+        await archive_factory(printer.id, print_name="Archive 3", tags="different")
+
+        response = await async_client.delete("/api/v1/archives/tags/delete-me")
+        assert response.status_code == 200
+        data = response.json()
+        assert data["affected"] == 2
+
+        # Verify the archives were updated
+        response = await async_client.get(f"/api/v1/archives/{a1.id}")
+        assert response.json()["tags"] == "keep"
+
+        response = await async_client.get(f"/api/v1/archives/{a2.id}")
+        # Should be None or empty when last tag is removed
+        assert response.json()["tags"] is None or response.json()["tags"] == ""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_tag_not_found(self, async_client: AsyncClient):
+        """Verify deleting non-existent tag returns 0 affected."""
+        response = await async_client.delete("/api/v1/archives/tags/nonexistent-tag")
+        assert response.status_code == 200
+        assert response.json()["affected"] == 0

+ 252 - 0
backend/tests/integration/test_smart_plugs_api.py

@@ -573,3 +573,255 @@ class TestSmartPlugsAPI:
 
         assert response.status_code == 400
         assert "not configured" in response.json()["detail"].lower()
+
+    # ========================================================================
+    # MQTT Integration tests
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_mqtt_plug(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):
+        """Verify MQTT plug can be created with topic and JSON paths."""
+        data = {
+            "name": "MQTT Energy Monitor",
+            "plug_type": "mqtt",
+            "mqtt_topic": "zigbee2mqtt/shelly-working-room",
+            "mqtt_power_path": "power_l1",
+            "mqtt_energy_path": "energy_l1",
+            "mqtt_state_path": "state_l1",
+            "mqtt_multiplier": 1.0,
+            "enabled": True,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["name"] == "MQTT Energy Monitor"
+        assert result["plug_type"] == "mqtt"
+        assert result["mqtt_topic"] == "zigbee2mqtt/shelly-working-room"
+        assert result["mqtt_power_path"] == "power_l1"
+        assert result["mqtt_energy_path"] == "energy_l1"
+        assert result["mqtt_state_path"] == "state_l1"
+        assert result["mqtt_multiplier"] == 1.0
+        assert result["ip_address"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_mqtt_plug_missing_topic(self, async_client: AsyncClient):
+        """Verify creating MQTT plug without topic fails."""
+        data = {
+            "name": "MQTT Plug",
+            "plug_type": "mqtt",
+            # Missing mqtt_topic
+            "mqtt_power_path": "power",
+            "enabled": True,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
+
+        assert response.status_code == 422  # Validation error
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_mqtt_plug_missing_topic(self, async_client: AsyncClient):
+        """Verify creating MQTT plug without any topic fails."""
+        data = {
+            "name": "MQTT Plug",
+            "plug_type": "mqtt",
+            # No topic configured at all
+            "enabled": True,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
+
+        assert response.status_code == 422  # Validation error
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_mqtt_plug_with_multiplier(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):
+        """Verify MQTT plug can use multiplier for unit conversion."""
+        data = {
+            "name": "MQTT mW to W",
+            "plug_type": "mqtt",
+            "mqtt_topic": "sensors/power",
+            "mqtt_power_path": "power_mw",
+            "mqtt_multiplier": 0.001,  # Convert mW to W
+            "enabled": True,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["mqtt_multiplier"] == 0.001
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_control_mqtt_plug_returns_error(self, async_client: AsyncClient, smart_plug_factory, db_session):
+        """Verify MQTT plugs cannot be controlled (monitor-only)."""
+        plug = await smart_plug_factory(
+            plug_type="mqtt",
+            mqtt_topic="test/topic",
+            mqtt_power_path="power",
+        )
+
+        response = await async_client.post(f"/api/v1/smart-plugs/{plug.id}/control", json={"action": "on"})
+
+        assert response.status_code == 400
+        assert "monitor-only" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_mqtt_plug_topic(self, async_client: AsyncClient, smart_plug_factory, db_session):
+        """Verify MQTT plug topic can be updated."""
+        plug = await smart_plug_factory(
+            plug_type="mqtt",
+            mqtt_topic="old/topic",
+            mqtt_power_path="power",
+        )
+
+        response = await async_client.patch(
+            f"/api/v1/smart-plugs/{plug.id}",
+            json={
+                "mqtt_topic": "new/topic",
+                "mqtt_power_path": "new_power",
+            },
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["mqtt_topic"] == "new/topic"
+        assert result["mqtt_power_path"] == "new_power"
+
+    # ========================================================================
+    # Enhanced MQTT Integration tests (separate topics per data type)
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_mqtt_plug_with_separate_topics(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):
+        """Verify MQTT plug can be created with separate topics for power, energy, and state."""
+        data = {
+            "name": "MQTT Separate Topics",
+            "plug_type": "mqtt",
+            "mqtt_power_topic": "zigbee/power",
+            "mqtt_power_path": "power_l1",
+            "mqtt_power_multiplier": 0.001,
+            "mqtt_energy_topic": "zigbee/energy",
+            "mqtt_energy_path": "energy_total",
+            "mqtt_energy_multiplier": 1.0,
+            "mqtt_state_topic": "zigbee/state",
+            "mqtt_state_path": "state",
+            "mqtt_state_on_value": "ON",
+            "enabled": True,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["name"] == "MQTT Separate Topics"
+        assert result["plug_type"] == "mqtt"
+        # Power fields
+        assert result["mqtt_power_topic"] == "zigbee/power"
+        assert result["mqtt_power_path"] == "power_l1"
+        assert result["mqtt_power_multiplier"] == 0.001
+        # Energy fields
+        assert result["mqtt_energy_topic"] == "zigbee/energy"
+        assert result["mqtt_energy_path"] == "energy_total"
+        assert result["mqtt_energy_multiplier"] == 1.0
+        # State fields
+        assert result["mqtt_state_topic"] == "zigbee/state"
+        assert result["mqtt_state_path"] == "state"
+        assert result["mqtt_state_on_value"] == "ON"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_mqtt_plug_energy_only(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):
+        """Verify MQTT plug can be created with only energy monitoring."""
+        data = {
+            "name": "Energy Only Monitor",
+            "plug_type": "mqtt",
+            "mqtt_energy_topic": "sensors/energy",
+            "mqtt_energy_path": "kwh",
+            "mqtt_energy_multiplier": 0.001,  # Wh to kWh
+            "enabled": True,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["mqtt_energy_topic"] == "sensors/energy"
+        assert result["mqtt_energy_path"] == "kwh"
+        assert result["mqtt_energy_multiplier"] == 0.001
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_mqtt_plug_state_only(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):
+        """Verify MQTT plug can be created with only state monitoring."""
+        data = {
+            "name": "State Only Monitor",
+            "plug_type": "mqtt",
+            "mqtt_state_topic": "switches/outlet",
+            "mqtt_state_path": "state",
+            "mqtt_state_on_value": "true",
+            "enabled": True,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["mqtt_state_topic"] == "switches/outlet"
+        assert result["mqtt_state_path"] == "state"
+        assert result["mqtt_state_on_value"] == "true"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_mqtt_plug_topic_only_succeeds(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):
+        """Verify creating MQTT plug with topic only (no path) succeeds for raw values."""
+        data = {
+            "name": "Raw MQTT Plug",
+            "plug_type": "mqtt",
+            # Topic only, no path - valid for raw numeric MQTT values
+            "mqtt_power_topic": "zigbee/power",
+            "enabled": True,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
+
+        assert response.status_code == 200  # Should succeed
+        result = response.json()
+        assert result["mqtt_power_topic"] == "zigbee/power"
+        assert result["mqtt_power_path"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_mqtt_plug_separate_multipliers(
+        self, async_client: AsyncClient, smart_plug_factory, db_session, mock_mqtt_smart_plug_service
+    ):
+        """Verify MQTT plug multipliers can be updated separately."""
+        plug = await smart_plug_factory(
+            plug_type="mqtt",
+            mqtt_power_topic="test/power",
+            mqtt_power_path="power",
+            mqtt_power_multiplier=1.0,
+            mqtt_energy_topic="test/energy",
+            mqtt_energy_path="energy",
+            mqtt_energy_multiplier=1.0,
+        )
+
+        response = await async_client.patch(
+            f"/api/v1/smart-plugs/{plug.id}",
+            json={
+                "mqtt_power_multiplier": 0.001,  # Change power multiplier only
+                "mqtt_energy_multiplier": 0.001,  # Change energy multiplier only
+            },
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["mqtt_power_multiplier"] == 0.001
+        assert result["mqtt_energy_multiplier"] == 0.001

+ 48 - 18
deploy/bambuddy.service

@@ -1,31 +1,61 @@
+# BamBuddy Systemd Service Template
+#
+# INSTALLATION:
+# 1. Copy this file to /etc/systemd/system/bambuddy.service
+# 2. Replace placeholders:
+#    - INSTALL_PATH: Where BamBuddy is installed (e.g., /opt/bambuddy)
+#    - SERVICE_USER: User to run as (e.g., bambuddy)
+#    - DATA_DIR: Data directory (e.g., /opt/bambuddy/data)
+#    - LOG_DIR: Log directory (e.g., /opt/bambuddy/logs)
+# 3. Run: sudo systemctl daemon-reload
+# 4. Run: sudo systemctl enable bambuddy
+# 5. Run: sudo systemctl start bambuddy
+#
+# Or use the install script: ./install/install.sh
+#
+
 [Unit]
-Description=BamBuddy Print Archive
+Description=BamBuddy - Bambu Lab Print Management
+Documentation=https://github.com/maziggy/bambuddy
 After=network.target
 
 [Service]
 Type=simple
-User=claude
-Group=claude
-WorkingDirectory=<dir>/bambuddy
-Environment="PATH=<dir/bambuddy/venv/bin"
+User=SERVICE_USER
+Group=SERVICE_USER
+WorkingDirectory=INSTALL_PATH
+
+# Environment file (optional - created by install script)
+EnvironmentFile=-INSTALL_PATH/.env
+
+# Use virtual environment
+Environment="PATH=INSTALL_PATH/venv/bin:/usr/local/bin:/usr/bin:/bin"
+
+# Server configuration
+ExecStart=INSTALL_PATH/venv/bin/uvicorn backend.app.main:app --host 0.0.0.0 --port ${PORT:-8000}
+
+# Restart policy
+Restart=on-failure
+RestartSec=5
 
-# Force kill after 10 seconds if graceful shutdown fails
+# Graceful shutdown
 TimeoutStopSec=10
 
-# Kill any zombie ffmpeg processes before starting/after stopping
-ExecStartPre=-/usr/bin/pkill -9 ffmpeg
-ExecStopPost=-/usr/bin/pkill -9 ffmpeg
+# Kill zombie ffmpeg processes (timelapse processing)
+ExecStartPre=-/usr/bin/pkill -9 -f "ffmpeg.*bambuddy"
+ExecStopPost=-/usr/bin/pkill -9 -f "ffmpeg.*bambuddy"
 
-# Ensure directories exist and have correct permissions before starting
-# The + prefix runs the command as root even though User=claude
-ExecStartPre=+/bin/mkdir -p <dir>/bambuddy/logs
-ExecStartPre=+/bin/mkdir -p <dir>/bambuddy/archive
-ExecStartPre=+/bin/chown -R <user>:<user> <dir>/bambuddy/logs
-ExecStartPre=+/bin/chown -R <user>:<user> <dir>/bambuddy/archive
+# Logging
+StandardOutput=journal
+StandardError=journal
+SyslogIdentifier=bambuddy
 
-ExecStart=<dir>/bambuddy/venv/bin/uvicorn backend.app.main:app --host 0.0.0.0 --port 8000
-Restart=always
-RestartSec=10
+# Security hardening
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+ReadWritePaths=DATA_DIR LOG_DIR INSTALL_PATH
 
 [Install]
 WantedBy=multi-user.target

+ 4 - 0
frontend/src/App.tsx

@@ -12,6 +12,7 @@ import { ProjectsPage } from './pages/ProjectsPage';
 import { ProjectDetailPage } from './pages/ProjectDetailPage';
 import { FileManagerPage } from './pages/FileManagerPage';
 import { CameraPage } from './pages/CameraPage';
+import { StreamOverlayPage } from './pages/StreamOverlayPage';
 import { ExternalLinkPage } from './pages/ExternalLinkPage';
 import { SystemInfoPage } from './pages/SystemInfoPage';
 import { LoginPage } from './pages/LoginPage';
@@ -108,6 +109,9 @@ function App() {
                 {/* Camera page - standalone, no layout, no WebSocket (doesn't need real-time updates) */}
                 <Route path="/camera/:printerId" element={<CameraPage />} />
 
+                {/* Stream overlay page - standalone for OBS/streaming embeds, no auth required */}
+                <Route path="/overlay/:printerId" element={<StreamOverlayPage />} />
+
                 {/* Main app with WebSocket for real-time updates */}
                 <Route element={<ProtectedRoute><WebSocketProvider><Layout /></WebSocketProvider></ProtectedRoute>}>
                   <Route index element={<PrintersPage />} />

+ 5 - 1
frontend/src/__tests__/components/EditArchiveModal.test.tsx

@@ -38,7 +38,11 @@ describe('EditArchiveModal', () => {
         return HttpResponse.json(mockProjects);
       }),
       http.get('/api/v1/archives/tags', () => {
-        return HttpResponse.json(['test', 'calibration', 'functional']);
+        return HttpResponse.json([
+          { name: 'test', count: 2 },
+          { name: 'calibration', count: 1 },
+          { name: 'functional', count: 3 },
+        ]);
       }),
       http.patch('/api/v1/archives/:id', async ({ request }) => {
         const body = await request.json();

+ 87 - 0
frontend/src/__tests__/components/SmartPlugCard.test.tsx

@@ -21,6 +21,24 @@ const createMockPlug = (overrides: Partial<SmartPlug> = {}): SmartPlug => ({
   plug_type: 'tasmota',
   ip_address: '192.168.1.100',
   ha_entity_id: null,
+  ha_power_entity: null,
+  ha_energy_today_entity: null,
+  ha_energy_total_entity: null,
+  // MQTT fields (legacy)
+  mqtt_topic: null,
+  mqtt_multiplier: 1.0,
+  // MQTT power fields
+  mqtt_power_topic: null,
+  mqtt_power_path: null,
+  mqtt_power_multiplier: 1.0,
+  // MQTT energy fields
+  mqtt_energy_topic: null,
+  mqtt_energy_path: null,
+  mqtt_energy_multiplier: 1.0,
+  // MQTT state fields
+  mqtt_state_topic: null,
+  mqtt_state_path: null,
+  mqtt_state_on_value: null,
   printer_id: 1,
   enabled: true,
   auto_on: true,
@@ -272,4 +290,73 @@ describe('SmartPlugCard', () => {
       expect(buttons.length).toBeGreaterThan(0);
     });
   });
+
+  describe('MQTT plugs', () => {
+    it('renders MQTT plug with topic instead of IP', () => {
+      const plug = createMockPlug({
+        plug_type: 'mqtt',
+        ip_address: null,
+        mqtt_topic: 'zigbee2mqtt/shelly-power',
+        mqtt_power_path: 'power_l1',
+      });
+      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
+
+      // Should show topic, not IP
+      expect(screen.getByText('zigbee2mqtt/shelly-power')).toBeInTheDocument();
+      expect(screen.queryByText('192.168.1.100')).not.toBeInTheDocument();
+    });
+
+    it('renders MQTT plug name correctly', () => {
+      const plug = createMockPlug({
+        name: 'MQTT Energy Monitor',
+        plug_type: 'mqtt',
+        ip_address: null,
+        mqtt_topic: 'sensors/power',
+        mqtt_power_path: 'power',
+      });
+      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
+
+      expect(screen.getByText('MQTT Energy Monitor')).toBeInTheDocument();
+    });
+
+    it('shows Monitor Only badge for MQTT plug', () => {
+      const plug = createMockPlug({
+        plug_type: 'mqtt',
+        ip_address: null,
+        mqtt_topic: 'test/topic',
+        mqtt_power_path: 'power',
+      });
+      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
+
+      expect(screen.getByText('Monitor Only')).toBeInTheDocument();
+    });
+
+    it('does not show power control buttons for MQTT plug', () => {
+      const plug = createMockPlug({
+        plug_type: 'mqtt',
+        ip_address: null,
+        mqtt_topic: 'test/topic',
+        mqtt_power_path: 'power',
+      });
+      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
+
+      // On/Off buttons should not be present for monitor-only plugs
+      expect(screen.queryByRole('button', { name: /^on$/i })).not.toBeInTheDocument();
+      expect(screen.queryByRole('button', { name: /^off$/i })).not.toBeInTheDocument();
+    });
+
+    it('shows Settings instead of Automation Settings for MQTT plug', async () => {
+      const plug = createMockPlug({
+        plug_type: 'mqtt',
+        ip_address: null,
+        mqtt_topic: 'test/topic',
+        mqtt_power_path: 'power',
+      });
+      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
+
+      // Should show "Settings" not "Automation Settings"
+      expect(screen.getByText('Settings')).toBeInTheDocument();
+      expect(screen.queryByText('Automation Settings')).not.toBeInTheDocument();
+    });
+  });
 });

+ 300 - 0
frontend/src/__tests__/components/TagManagementModal.test.tsx

@@ -0,0 +1,300 @@
+/**
+ * Tests for the TagManagementModal component.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { TagManagementModal } from '../../components/TagManagementModal';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const mockTags = [
+  { name: 'functional', count: 5 },
+  { name: 'calibration', count: 3 },
+  { name: 'test', count: 2 },
+  { name: 'art', count: 1 },
+];
+
+describe('TagManagementModal', () => {
+  const mockOnClose = vi.fn();
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    server.use(
+      http.get('/api/v1/archives/tags', () => {
+        return HttpResponse.json(mockTags);
+      }),
+      http.put('/api/v1/archives/tags/:tagName', async () => {
+        return HttpResponse.json({ affected: 2 });
+      }),
+      http.delete('/api/v1/archives/tags/:tagName', () => {
+        return HttpResponse.json({ affected: 1 });
+      })
+    );
+  });
+
+  describe('rendering', () => {
+    it('renders the modal title', async () => {
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      expect(screen.getByText('Manage Tags')).toBeInTheDocument();
+    });
+
+    it('shows loading state initially', () => {
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      // Should show loading spinner before data loads
+      expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
+    });
+
+    it('displays tags with counts', async () => {
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('functional')).toBeInTheDocument();
+        expect(screen.getByText('5')).toBeInTheDocument();
+        expect(screen.getByText('calibration')).toBeInTheDocument();
+        expect(screen.getByText('3')).toBeInTheDocument();
+      });
+    });
+
+    it('shows total tag count and usage', async () => {
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        // 4 tags, 11 total usages
+        expect(screen.getByText(/4 tags across 11 usages/i)).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('search functionality', () => {
+    it('filters tags by search input', async () => {
+      const user = userEvent.setup();
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('functional')).toBeInTheDocument();
+      });
+
+      const searchInput = screen.getByPlaceholderText('Search tags...');
+      await user.type(searchInput, 'cal');
+
+      await waitFor(() => {
+        expect(screen.getByText('calibration')).toBeInTheDocument();
+        expect(screen.queryByText('functional')).not.toBeInTheDocument();
+        expect(screen.queryByText('art')).not.toBeInTheDocument();
+      });
+    });
+
+    it('shows no results message when search has no matches', async () => {
+      const user = userEvent.setup();
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('functional')).toBeInTheDocument();
+      });
+
+      const searchInput = screen.getByPlaceholderText('Search tags...');
+      await user.type(searchInput, 'nonexistent');
+
+      await waitFor(() => {
+        expect(screen.getByText('No tags match your search')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('sorting', () => {
+    it('sorts by count by default', async () => {
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        const tagElements = screen.getAllByText(/functional|calibration|test|art/);
+        // First should be functional (count 5)
+        expect(tagElements[0]).toHaveTextContent('functional');
+      });
+    });
+
+    it('can sort by name', async () => {
+      const user = userEvent.setup();
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('functional')).toBeInTheDocument();
+      });
+
+      const sortSelect = screen.getByDisplayValue('Sort by Count');
+      await user.selectOptions(sortSelect, 'name');
+
+      await waitFor(() => {
+        const tagElements = screen.getAllByText(/functional|calibration|test|art/);
+        // First should be 'art' alphabetically
+        expect(tagElements[0]).toHaveTextContent('art');
+      });
+    });
+  });
+
+  describe('rename functionality', () => {
+    it('enters edit mode when clicking edit button', async () => {
+      const user = userEvent.setup();
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('functional')).toBeInTheDocument();
+      });
+
+      // Find the tag row and click its edit button
+      const tagRow = screen.getByText('functional').closest('div');
+      const editButton = within(tagRow!).getByTitle('Rename tag');
+      await user.click(editButton);
+
+      // Should show input with current value
+      await waitFor(() => {
+        expect(screen.getByDisplayValue('functional')).toBeInTheDocument();
+      });
+    });
+
+    it('submits rename on Enter key', async () => {
+      const user = userEvent.setup();
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('functional')).toBeInTheDocument();
+      });
+
+      const tagRow = screen.getByText('functional').closest('div');
+      const editButton = within(tagRow!).getByTitle('Rename tag');
+      await user.click(editButton);
+
+      const input = screen.getByDisplayValue('functional');
+      await user.clear(input);
+      await user.type(input, 'new-name{Enter}');
+
+      // Should show success (mutation called)
+      await waitFor(() => {
+        // After successful rename, edit mode should close
+        expect(screen.queryByDisplayValue('new-name')).not.toBeInTheDocument();
+      });
+    });
+
+    it('cancels edit on Escape key', async () => {
+      const user = userEvent.setup();
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('functional')).toBeInTheDocument();
+      });
+
+      const tagRow = screen.getByText('functional').closest('div');
+      const editButton = within(tagRow!).getByTitle('Rename tag');
+      await user.click(editButton);
+
+      const input = screen.getByDisplayValue('functional');
+      await user.type(input, '-modified{Escape}');
+
+      // Should exit edit mode without saving
+      await waitFor(() => {
+        expect(screen.queryByDisplayValue('functional-modified')).not.toBeInTheDocument();
+        expect(screen.getByText('functional')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('delete functionality', () => {
+    it('shows delete confirmation when clicking delete button', async () => {
+      const user = userEvent.setup();
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('functional')).toBeInTheDocument();
+      });
+
+      const tagRow = screen.getByText('functional').closest('div');
+      const deleteButton = within(tagRow!).getByTitle('Delete tag');
+      await user.click(deleteButton);
+
+      await waitFor(() => {
+        expect(screen.getByText(/Delete "functional" from 5 archives?/i)).toBeInTheDocument();
+      });
+    });
+
+    it('cancels delete confirmation on X button', async () => {
+      const user = userEvent.setup();
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('functional')).toBeInTheDocument();
+      });
+
+      const tagRow = screen.getByText('functional').closest('div');
+      const deleteButton = within(tagRow!).getByTitle('Delete tag');
+      await user.click(deleteButton);
+
+      await waitFor(() => {
+        expect(screen.getByText(/Delete "functional"/i)).toBeInTheDocument();
+      });
+
+      // Find the confirmation row and click the cancel (X) button within it
+      const confirmationText = screen.getByText(/Delete "functional"/i);
+      const confirmationRow = confirmationText.closest('div');
+      // The X button is the last button in the confirmation row
+      const buttons = within(confirmationRow!.parentElement!).getAllByRole('button');
+      const cancelButton = buttons[buttons.length - 1]; // X button is last
+      await user.click(cancelButton);
+
+      await waitFor(() => {
+        // Should return to normal display - the tag name should be visible again
+        expect(screen.getByText('functional')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('modal behavior', () => {
+    it('calls onClose when close button is clicked', async () => {
+      const user = userEvent.setup();
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Manage Tags')).toBeInTheDocument();
+      });
+
+      // Find close button in header (X icon)
+      const headerCloseButton = screen.getAllByRole('button')[0];
+      await user.click(headerCloseButton);
+
+      expect(mockOnClose).toHaveBeenCalledTimes(1);
+    });
+
+    it('calls onClose when Close button is clicked', async () => {
+      const user = userEvent.setup();
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Manage Tags')).toBeInTheDocument();
+      });
+
+      const closeButton = screen.getByRole('button', { name: /close/i });
+      await user.click(closeButton);
+
+      expect(mockOnClose).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe('empty state', () => {
+    it('shows empty message when no tags exist', async () => {
+      server.use(
+        http.get('/api/v1/archives/tags', () => {
+          return HttpResponse.json([]);
+        })
+      );
+
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('No tags found')).toBeInTheDocument();
+      });
+    });
+  });
+});

+ 230 - 0
frontend/src/__tests__/pages/StreamOverlayPage.test.tsx

@@ -0,0 +1,230 @@
+/**
+ * Tests for the StreamOverlayPage component.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { screen, waitFor, render as rtlRender } from '@testing-library/react';
+import { StreamOverlayPage } from '../../pages/StreamOverlayPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { ThemeProvider } from '../../contexts/ThemeContext';
+import { ToastProvider } from '../../contexts/ToastContext';
+
+const mockPrinter = {
+  id: 1,
+  name: 'X1 Carbon',
+  ip_address: '192.168.1.100',
+  serial_number: '00M09A350100001',
+  access_code: '12345678',
+  model: 'X1C',
+  enabled: true,
+};
+
+const mockStatusIdle = {
+  id: 1,
+  name: 'X1 Carbon',
+  connected: true,
+  state: 'IDLE',
+  progress: 0,
+  current_print: null,
+  remaining_time: null,
+  layer_num: null,
+  total_layers: null,
+  stg_cur_name: null,
+};
+
+const mockStatusPrinting = {
+  id: 1,
+  name: 'X1 Carbon',
+  connected: true,
+  state: 'RUNNING',
+  progress: 45,
+  current_print: 'Benchy.gcode.3mf',
+  remaining_time: 82,
+  layer_num: 150,
+  total_layers: 300,
+  stg_cur_name: null,
+};
+
+// Custom render for StreamOverlayPage
+function renderOverlayPage(printerId: number, queryParams = '') {
+  const queryClient = new QueryClient({
+    defaultOptions: {
+      queries: { retry: false, gcTime: 0 },
+      mutations: { retry: false },
+    },
+  });
+
+  return rtlRender(
+    <QueryClientProvider client={queryClient}>
+      <MemoryRouter initialEntries={[`/overlay/${printerId}${queryParams}`]}>
+        <ThemeProvider>
+          <ToastProvider>
+            <Routes>
+              <Route path="/overlay/:printerId" element={<StreamOverlayPage />} />
+            </Routes>
+          </ToastProvider>
+        </ThemeProvider>
+      </MemoryRouter>
+    </QueryClientProvider>
+  );
+}
+
+describe('StreamOverlayPage', () => {
+  const originalTitle = document.title;
+
+  beforeEach(() => {
+    // Mock WebSocket
+    vi.stubGlobal('WebSocket', vi.fn().mockImplementation(() => ({
+      close: vi.fn(),
+      onmessage: null,
+      onerror: null,
+    })));
+
+    server.use(
+      http.get('/api/v1/printers/:id', () => {
+        return HttpResponse.json(mockPrinter);
+      }),
+      http.get('/api/v1/printers/:id/status', () => {
+        return HttpResponse.json(mockStatusIdle);
+      })
+    );
+  });
+
+  afterEach(() => {
+    document.title = originalTitle;
+    vi.unstubAllGlobals();
+  });
+
+  describe('rendering', () => {
+    it('renders overlay page for printer', async () => {
+      renderOverlayPage(1);
+
+      await waitFor(() => {
+        expect(screen.getByText('Printer is idle')).toBeInTheDocument();
+      });
+    });
+
+    it('shows Bambuddy logo', async () => {
+      renderOverlayPage(1);
+
+      await waitFor(() => {
+        expect(screen.getByAltText('Bambuddy')).toBeInTheDocument();
+      });
+    });
+
+    it('logo links to GitHub', async () => {
+      renderOverlayPage(1);
+
+      await waitFor(() => {
+        const logo = screen.getByAltText('Bambuddy');
+        const link = logo.closest('a');
+        expect(link).toHaveAttribute('href', 'https://github.com/maziggy/bambuddy');
+      });
+    });
+  });
+
+  describe('printing state', () => {
+    beforeEach(() => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json(mockStatusPrinting);
+        })
+      );
+    });
+
+    it('shows filename when printing', async () => {
+      renderOverlayPage(1);
+
+      await waitFor(() => {
+        expect(screen.getByText('Benchy')).toBeInTheDocument();
+      });
+    });
+
+    it('shows progress percentage', async () => {
+      renderOverlayPage(1);
+
+      await waitFor(() => {
+        expect(screen.getByText('45%')).toBeInTheDocument();
+      });
+    });
+
+    it('shows layer count', async () => {
+      renderOverlayPage(1);
+
+      await waitFor(() => {
+        expect(screen.getByText('150')).toBeInTheDocument();
+        expect(screen.getByText('300')).toBeInTheDocument();
+      });
+    });
+
+    it('shows status text', async () => {
+      renderOverlayPage(1);
+
+      await waitFor(() => {
+        expect(screen.getByText('Printing')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('invalid printer', () => {
+    it('shows invalid printer message for ID 0', async () => {
+      renderOverlayPage(0);
+
+      await waitFor(() => {
+        expect(screen.getByText('Invalid printer ID')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('query parameters', () => {
+    it('respects size parameter', async () => {
+      renderOverlayPage(1, '?size=large');
+
+      await waitFor(() => {
+        // Just verify it renders without error
+        expect(screen.getByAltText('Bambuddy')).toBeInTheDocument();
+      });
+    });
+
+    it('respects show parameter to hide elements', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json(mockStatusPrinting);
+        })
+      );
+
+      renderOverlayPage(1, '?show=progress');
+
+      await waitFor(() => {
+        // Progress should be visible
+        expect(screen.getByText('45%')).toBeInTheDocument();
+        // Status text should be hidden when not in show list
+        expect(screen.queryByText('Printing')).not.toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('offline state', () => {
+    beforeEach(() => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json({
+            ...mockStatusIdle,
+            connected: false,
+          });
+        })
+      );
+    });
+
+    it('shows offline message when printer disconnected', async () => {
+      renderOverlayPage(1);
+
+      await waitFor(() => {
+        expect(screen.getByText('Printer offline')).toBeInTheDocument();
+      });
+    });
+  });
+});

+ 75 - 8
frontend/src/api/client.ts

@@ -341,6 +341,11 @@ export interface ArchiveStats {
   total_energy_cost: number;
 }
 
+export interface TagInfo {
+  name: string;
+  count: number;
+}
+
 export interface FailureAnalysis {
   period_days: number;
   total_prints: number;
@@ -847,13 +852,29 @@ export interface CloudDevice {
 export interface SmartPlug {
   id: number;
   name: string;
-  plug_type: 'tasmota' | 'homeassistant';
+  plug_type: 'tasmota' | 'homeassistant' | 'mqtt';
   ip_address: string | null;  // Required for Tasmota
-  ha_entity_id: string | null;  // Required for Home Assistant (e.g., "switch.printer_plug")
+  ha_entity_id: string | null;  // Required for Home Assistant (e.g., "switch.printer_plug", "script.turn_on_printer")
   // Home Assistant energy sensor entities (optional)
   ha_power_entity: string | null;
   ha_energy_today_entity: string | null;
   ha_energy_total_entity: string | null;
+  // MQTT fields (required when plug_type="mqtt")
+  // Legacy field - kept for backward compatibility
+  mqtt_topic: string | null;  // Deprecated, use mqtt_power_topic
+  mqtt_multiplier: number;  // Deprecated, use mqtt_power_multiplier
+  // Power monitoring
+  mqtt_power_topic: string | null;  // Topic for power data
+  mqtt_power_path: string | null;  // e.g., "power_l1" or "data.power"
+  mqtt_power_multiplier: number;  // Unit conversion for power
+  // Energy monitoring
+  mqtt_energy_topic: string | null;  // Topic for energy data
+  mqtt_energy_path: string | null;  // e.g., "energy_l1"
+  mqtt_energy_multiplier: number;  // Unit conversion for energy
+  // State monitoring
+  mqtt_state_topic: string | null;  // Topic for state data
+  mqtt_state_path: string | null;  // e.g., "state_l1" for ON/OFF
+  mqtt_state_on_value: string | null;  // What value means "ON" (e.g., "ON", "true", "1")
   printer_id: number | null;
   enabled: boolean;
   auto_on: boolean;
@@ -872,8 +893,9 @@ export interface SmartPlug {
   schedule_enabled: boolean;
   schedule_on_time: string | null;
   schedule_off_time: string | null;
-  // Switchbar visibility
+  // Visibility options
   show_in_switchbar: boolean;
+  show_on_printer_card: boolean;  // For scripts: show on printer card
   // Status
   last_state: string | null;
   last_checked: string | null;
@@ -884,13 +906,29 @@ export interface SmartPlug {
 
 export interface SmartPlugCreate {
   name: string;
-  plug_type?: 'tasmota' | 'homeassistant';
+  plug_type?: 'tasmota' | 'homeassistant' | 'mqtt';
   ip_address?: string | null;  // Required for Tasmota
   ha_entity_id?: string | null;  // Required for Home Assistant
   // Home Assistant energy sensor entities (optional)
   ha_power_entity?: string | null;
   ha_energy_today_entity?: string | null;
   ha_energy_total_entity?: string | null;
+  // MQTT fields (required when plug_type="mqtt")
+  // Legacy fields - kept for backward compatibility
+  mqtt_topic?: string | null;
+  mqtt_multiplier?: number;
+  // Power monitoring
+  mqtt_power_topic?: string | null;
+  mqtt_power_path?: string | null;
+  mqtt_power_multiplier?: number;
+  // Energy monitoring
+  mqtt_energy_topic?: string | null;
+  mqtt_energy_path?: string | null;
+  mqtt_energy_multiplier?: number;
+  // State monitoring
+  mqtt_state_topic?: string | null;
+  mqtt_state_path?: string | null;
+  mqtt_state_on_value?: string | null;
   printer_id?: number | null;
   enabled?: boolean;
   auto_on?: boolean;
@@ -908,19 +946,35 @@ export interface SmartPlugCreate {
   schedule_enabled?: boolean;
   schedule_on_time?: string | null;
   schedule_off_time?: string | null;
-  // Switchbar visibility
+  // Visibility options
   show_in_switchbar?: boolean;
+  show_on_printer_card?: boolean;
 }
 
 export interface SmartPlugUpdate {
   name?: string;
-  plug_type?: 'tasmota' | 'homeassistant';
+  plug_type?: 'tasmota' | 'homeassistant' | 'mqtt';
   ip_address?: string | null;
   ha_entity_id?: string | null;
   // Home Assistant energy sensor entities (optional)
   ha_power_entity?: string | null;
   ha_energy_today_entity?: string | null;
   ha_energy_total_entity?: string | null;
+  // MQTT fields (legacy)
+  mqtt_topic?: string | null;
+  mqtt_multiplier?: number;
+  // MQTT power fields
+  mqtt_power_topic?: string | null;
+  mqtt_power_path?: string | null;
+  mqtt_power_multiplier?: number;
+  // MQTT energy fields
+  mqtt_energy_topic?: string | null;
+  mqtt_energy_path?: string | null;
+  mqtt_energy_multiplier?: number;
+  // MQTT state fields
+  mqtt_state_topic?: string | null;
+  mqtt_state_path?: string | null;
+  mqtt_state_on_value?: string | null;
   printer_id?: number | null;
   enabled?: boolean;
   auto_on?: boolean;
@@ -938,8 +992,9 @@ export interface SmartPlugUpdate {
   schedule_enabled?: boolean;
   schedule_on_time?: string | null;
   schedule_off_time?: string | null;
-  // Switchbar visibility
+  // Visibility options
   show_in_switchbar?: boolean;
+  show_on_printer_card?: boolean;
 }
 
 // Home Assistant entity for smart plug selection
@@ -947,7 +1002,7 @@ export interface HAEntity {
   entity_id: string;
   friendly_name: string;
   state: string | null;
-  domain: string;  // "switch", "light", "input_boolean"
+  domain: string;  // "switch", "light", "input_boolean", "script"
 }
 
 // Home Assistant sensor entity for energy monitoring
@@ -2050,6 +2105,17 @@ export const api = {
   deleteArchive: (id: number) =>
     request<void>(`/archives/${id}`, { method: 'DELETE' }),
   getArchiveStats: () => request<ArchiveStats>('/archives/stats'),
+  // Tag management
+  getTags: () => request<TagInfo[]>('/archives/tags'),
+  renameTag: (oldName: string, newName: string) =>
+    request<{ affected: number }>(`/archives/tags/${encodeURIComponent(oldName)}`, {
+      method: 'PUT',
+      body: JSON.stringify({ new_name: newName }),
+    }),
+  deleteTag: (name: string) =>
+    request<{ affected: number }>(`/archives/tags/${encodeURIComponent(name)}`, {
+      method: 'DELETE',
+    }),
   recalculateCosts: () =>
     request<{ message: string; updated: number }>('/archives/recalculate-costs', { method: 'POST' }),
   getFailureAnalysis: (options?: { days?: number; printerId?: number; projectId?: number }) => {
@@ -2543,6 +2609,7 @@ export const api = {
   getSmartPlugs: () => request<SmartPlug[]>('/smart-plugs/'),
   getSmartPlug: (id: number) => request<SmartPlug>(`/smart-plugs/${id}`),
   getSmartPlugByPrinter: (printerId: number) => request<SmartPlug | null>(`/smart-plugs/by-printer/${printerId}`),
+  getScriptPlugsByPrinter: (printerId: number) => request<SmartPlug[]>(`/smart-plugs/by-printer/${printerId}/scripts`),
   createSmartPlug: (data: SmartPlugCreate) =>
     request<SmartPlug>('/smart-plugs/', {
       method: 'POST',

+ 275 - 66
frontend/src/components/AddSmartPlugModal.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useRef } from 'react';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
-import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock, LayoutGrid, Search, Plug, Power, Home } from 'lucide-react';
+import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock, LayoutGrid, Search, Plug, Power, Home, Radio } from 'lucide-react';
 import { api } from '../api/client';
 import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate, DiscoveredTasmotaDevice } from '../api/client';
 import { Button } from './Button';
@@ -15,7 +15,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
   const isEditing = !!plug;
 
   // Plug type selection
-  const [plugType, setPlugType] = useState<'tasmota' | 'homeassistant'>(plug?.plug_type || 'tasmota');
+  const [plugType, setPlugType] = useState<'tasmota' | 'homeassistant' | 'mqtt'>(plug?.plug_type || 'tasmota');
 
   const [name, setName] = useState(plug?.name || '');
   // Tasmota fields
@@ -24,6 +24,22 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
   const [password, setPassword] = useState(plug?.password || '');
   // Home Assistant fields
   const [haEntityId, setHaEntityId] = useState(plug?.ha_entity_id || '');
+  // MQTT fields - Power
+  const [mqttPowerTopic, setMqttPowerTopic] = useState(plug?.mqtt_power_topic || plug?.mqtt_topic || '');
+  const [mqttPowerPath, setMqttPowerPath] = useState(plug?.mqtt_power_path || '');
+  const [mqttPowerMultiplier, setMqttPowerMultiplier] = useState<string>(
+    (plug?.mqtt_power_multiplier ?? plug?.mqtt_multiplier ?? 1).toString()
+  );
+  // MQTT fields - Energy
+  const [mqttEnergyTopic, setMqttEnergyTopic] = useState(plug?.mqtt_energy_topic || '');
+  const [mqttEnergyPath, setMqttEnergyPath] = useState(plug?.mqtt_energy_path || '');
+  const [mqttEnergyMultiplier, setMqttEnergyMultiplier] = useState<string>(
+    (plug?.mqtt_energy_multiplier ?? 1).toString()
+  );
+  // MQTT fields - State
+  const [mqttStateTopic, setMqttStateTopic] = useState(plug?.mqtt_state_topic || '');
+  const [mqttStatePath, setMqttStatePath] = useState(plug?.mqtt_state_path || '');
+  const [mqttStateOnValue, setMqttStateOnValue] = useState(plug?.mqtt_state_on_value || '');
   // HA energy sensor entities (optional)
   const [haPowerEntity, setHaPowerEntity] = useState(plug?.ha_power_entity || '');
   const [haEnergyTodayEntity, setHaEnergyTodayEntity] = useState(plug?.ha_energy_today_entity || '');
@@ -279,6 +295,18 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
       return;
     }
 
+    if (plugType === 'mqtt') {
+      // Check that at least one topic is configured (path is optional)
+      const hasPower = mqttPowerTopic.trim();
+      const hasEnergy = mqttEnergyTopic.trim();
+      const hasState = mqttStateTopic.trim();
+
+      if (!hasPower && !hasEnergy && !hasState) {
+        setError('At least one MQTT topic must be configured for power, energy, or state monitoring');
+        return;
+      }
+    }
+
     const data = {
       name: name.trim(),
       plug_type: plugType,
@@ -288,6 +316,18 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
       ha_power_entity: plugType === 'homeassistant' ? (haPowerEntity || null) : null,
       ha_energy_today_entity: plugType === 'homeassistant' ? (haEnergyTodayEntity || null) : null,
       ha_energy_total_entity: plugType === 'homeassistant' ? (haEnergyTotalEntity || null) : null,
+      // MQTT power fields
+      mqtt_power_topic: plugType === 'mqtt' ? (mqttPowerTopic.trim() || null) : null,
+      mqtt_power_path: plugType === 'mqtt' ? (mqttPowerPath.trim() || null) : null,
+      mqtt_power_multiplier: plugType === 'mqtt' ? (parseFloat(mqttPowerMultiplier) || 1) : 1,
+      // MQTT energy fields
+      mqtt_energy_topic: plugType === 'mqtt' ? (mqttEnergyTopic.trim() || null) : null,
+      mqtt_energy_path: plugType === 'mqtt' ? (mqttEnergyPath.trim() || null) : null,
+      mqtt_energy_multiplier: plugType === 'mqtt' ? (parseFloat(mqttEnergyMultiplier) || 1) : 1,
+      // MQTT state fields
+      mqtt_state_topic: plugType === 'mqtt' ? (mqttStateTopic.trim() || null) : null,
+      mqtt_state_path: plugType === 'mqtt' ? (mqttStatePath.trim() || null) : null,
+      mqtt_state_on_value: plugType === 'mqtt' ? (mqttStateOnValue.trim() || null) : null,
       username: plugType === 'tasmota' ? (username.trim() || null) : null,
       password: plugType === 'tasmota' ? (password.trim() || null) : null,
       printer_id: printerId,
@@ -352,7 +392,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
                   setTestResult(null);
                   setError(null);
                 }}
-                className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium transition-colors ${
+                className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${
                   plugType === 'tasmota'
                     ? 'bg-bambu-green text-white'
                     : 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary'
@@ -368,14 +408,30 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
                   setTestResult(null);
                   setError(null);
                 }}
-                className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium transition-colors ${
+                className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${
                   plugType === 'homeassistant'
                     ? 'bg-bambu-green text-white'
                     : 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary'
                 }`}
               >
                 <Home className="w-4 h-4" />
-                Home Assistant
+                HA
+              </button>
+              <button
+                type="button"
+                onClick={() => {
+                  setPlugType('mqtt');
+                  setTestResult(null);
+                  setError(null);
+                }}
+                className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${
+                  plugType === 'mqtt'
+                    ? 'bg-bambu-green text-white'
+                    : 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary'
+                }`}
+              >
+                <Radio className="w-4 h-4" />
+                MQTT
               </button>
             </div>
           )}
@@ -857,6 +913,155 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
             </div>
           )}
 
+          {/* MQTT Configuration - only show when MQTT is selected */}
+          {plugType === 'mqtt' && (
+            <div className="space-y-3">
+              {/* MQTT broker not configured */}
+              {!settings?.mqtt_broker && (
+                <div className="p-3 bg-yellow-500/20 border border-yellow-500/50 rounded-lg text-sm text-yellow-400">
+                  MQTT broker not configured. Set broker address in{' '}
+                  <span className="font-medium">Settings → Network → MQTT Publishing</span>
+                  {' '}(you don't need to enable publishing, just fill in the broker details).
+                </div>
+              )}
+
+              {/* MQTT broker configured - show fields */}
+              {settings?.mqtt_broker && (
+                <>
+                  <div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg text-sm text-blue-300">
+                    <p className="font-medium mb-1">Monitor Only</p>
+                    <p className="text-xs opacity-80">
+                      MQTT plugs receive power/energy data via MQTT subscription. On/off control is not available - use your MQTT broker or home automation system.
+                    </p>
+                  </div>
+
+                  {/* Power Section */}
+                  <div className="space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
+                    <p className="text-white font-medium text-sm">Power Monitoring</p>
+                    <div>
+                      <label className="block text-sm text-bambu-gray mb-1">Topic</label>
+                      <input
+                        type="text"
+                        value={mqttPowerTopic}
+                        onChange={(e) => setMqttPowerTopic(e.target.value)}
+                        placeholder="zigbee2mqtt/shelly-working-room"
+                        className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                      />
+                    </div>
+                    <div className="grid grid-cols-2 gap-3">
+                      <div>
+                        <label className="block text-sm text-bambu-gray mb-1">JSON Path</label>
+                        <input
+                          type="text"
+                          value={mqttPowerPath}
+                          onChange={(e) => setMqttPowerPath(e.target.value)}
+                          placeholder="power_l1"
+                          className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                        />
+                      </div>
+                      <div>
+                        <label className="block text-sm text-bambu-gray mb-1">Multiplier</label>
+                        <input
+                          type="text"
+                          value={mqttPowerMultiplier}
+                          onChange={(e) => setMqttPowerMultiplier(e.target.value)}
+                          placeholder="1"
+                          className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                        />
+                      </div>
+                    </div>
+                    <p className="text-xs text-bambu-gray">
+                      JSON path extracts value from JSON payload (e.g., "power_l1"). Leave empty if topic publishes raw numeric values.<br/>
+                      Use multiplier 0.001 for mW→W, 1000 for kW→W.
+                    </p>
+                  </div>
+
+                  {/* Energy Section */}
+                  <div className="space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
+                    <p className="text-white font-medium text-sm">Energy Monitoring <span className="text-bambu-gray font-normal">(optional)</span></p>
+                    <div>
+                      <label className="block text-sm text-bambu-gray mb-1">Topic</label>
+                      <input
+                        type="text"
+                        value={mqttEnergyTopic}
+                        onChange={(e) => setMqttEnergyTopic(e.target.value)}
+                        placeholder="Same as power topic, or different"
+                        className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                      />
+                    </div>
+                    <div className="grid grid-cols-2 gap-3">
+                      <div>
+                        <label className="block text-sm text-bambu-gray mb-1">JSON Path</label>
+                        <input
+                          type="text"
+                          value={mqttEnergyPath}
+                          onChange={(e) => setMqttEnergyPath(e.target.value)}
+                          placeholder="energy_l1"
+                          className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                        />
+                      </div>
+                      <div>
+                        <label className="block text-sm text-bambu-gray mb-1">Multiplier</label>
+                        <input
+                          type="text"
+                          value={mqttEnergyMultiplier}
+                          onChange={(e) => setMqttEnergyMultiplier(e.target.value)}
+                          placeholder="1"
+                          className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                        />
+                      </div>
+                    </div>
+                    <p className="text-xs text-bambu-gray">
+                      JSON path extracts value from JSON payload. Leave empty for raw values.<br/>
+                      Use multiplier 0.001 for Wh→kWh, 1000 for MWh→kWh.
+                    </p>
+                  </div>
+
+                  {/* State Section */}
+                  <div className="space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
+                    <p className="text-white font-medium text-sm">State Monitoring <span className="text-bambu-gray font-normal">(optional)</span></p>
+                    <div>
+                      <label className="block text-sm text-bambu-gray mb-1">Topic</label>
+                      <input
+                        type="text"
+                        value={mqttStateTopic}
+                        onChange={(e) => setMqttStateTopic(e.target.value)}
+                        placeholder="Same as power topic, or different"
+                        className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                      />
+                    </div>
+                    <div className="grid grid-cols-2 gap-3">
+                      <div>
+                        <label className="block text-sm text-bambu-gray mb-1">JSON Path</label>
+                        <input
+                          type="text"
+                          value={mqttStatePath}
+                          onChange={(e) => setMqttStatePath(e.target.value)}
+                          placeholder="state_l1"
+                          className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                        />
+                      </div>
+                      <div>
+                        <label className="block text-sm text-bambu-gray mb-1">ON Value</label>
+                        <input
+                          type="text"
+                          value={mqttStateOnValue}
+                          onChange={(e) => setMqttStateOnValue(e.target.value)}
+                          placeholder="ON, true, 1"
+                          className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                        />
+                      </div>
+                    </div>
+                    <p className="text-xs text-bambu-gray">
+                      JSON path extracts value from JSON payload. Leave empty for raw values.<br/>
+                      ON value: the exact string that means "ON". Leave empty for auto-detect (ON, true, 1).
+                    </p>
+                  </div>
+                </>
+              )}
+            </div>
+          )}
+
           {/* IP Address - only show for Tasmota */}
           {plugType === 'tasmota' && (
             <div>
@@ -959,25 +1164,27 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
             </>
           )}
 
-          {/* Link to Printer */}
-          <div>
-            <label className="block text-sm text-bambu-gray mb-1">Link to Printer</label>
-            <select
-              value={printerId ?? ''}
-              onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
-              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-            >
-              <option value="">No printer (manual control only)</option>
-              {availablePrinters?.map((p) => (
-                <option key={p.id} value={p.id}>
-                  {p.name}
-                </option>
-              ))}
-            </select>
-            <p className="text-xs text-bambu-gray mt-1">
-              Linking enables automatic on/off when prints start/complete
-            </p>
-          </div>
+          {/* Link to Printer - not shown for MQTT plugs (monitor-only) */}
+          {plugType !== 'mqtt' && (
+            <div>
+              <label className="block text-sm text-bambu-gray mb-1">Link to Printer</label>
+              <select
+                value={printerId ?? ''}
+                onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
+                className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+              >
+                <option value="">No printer (manual control only)</option>
+                {availablePrinters?.map((p) => (
+                  <option key={p.id} value={p.id}>
+                    {p.name}
+                  </option>
+                ))}
+              </select>
+              <p className="text-xs text-bambu-gray mt-1">
+                Linking enables automatic on/off when prints start/complete
+              </p>
+            </div>
+          )}
 
           {/* Power Alerts */}
           <div className="border-t border-bambu-dark-tertiary pt-4">
@@ -1031,51 +1238,53 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
             )}
           </div>
 
-          {/* Schedule */}
-          <div className="border-t border-bambu-dark-tertiary pt-4">
-            <div className="flex items-center justify-between mb-3">
-              <div className="flex items-center gap-2">
-                <Clock className="w-4 h-4 text-bambu-green" />
-                <span className="text-white font-medium">Daily Schedule</span>
+          {/* Schedule - not shown for MQTT plugs (monitor-only) */}
+          {plugType !== 'mqtt' && (
+            <div className="border-t border-bambu-dark-tertiary pt-4">
+              <div className="flex items-center justify-between mb-3">
+                <div className="flex items-center gap-2">
+                  <Clock className="w-4 h-4 text-bambu-green" />
+                  <span className="text-white font-medium">Daily Schedule</span>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={scheduleEnabled}
+                    onChange={(e) => setScheduleEnabled(e.target.checked)}
+                    className="sr-only peer"
+                  />
+                  <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
               </div>
-              <label className="relative inline-flex items-center cursor-pointer">
-                <input
-                  type="checkbox"
-                  checked={scheduleEnabled}
-                  onChange={(e) => setScheduleEnabled(e.target.checked)}
-                  className="sr-only peer"
-                />
-                <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
-              </label>
-            </div>
-            {scheduleEnabled && (
-              <div className="space-y-3">
-                <div className="grid grid-cols-2 gap-3">
-                  <div>
-                    <label className="block text-sm text-bambu-gray mb-1">Turn On at</label>
-                    <input
-                      type="time"
-                      value={scheduleOnTime}
-                      onChange={(e) => setScheduleOnTime(e.target.value)}
-                      className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-                    />
-                  </div>
-                  <div>
-                    <label className="block text-sm text-bambu-gray mb-1">Turn Off at</label>
-                    <input
-                      type="time"
-                      value={scheduleOffTime}
-                      onChange={(e) => setScheduleOffTime(e.target.value)}
-                      className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-                    />
+              {scheduleEnabled && (
+                <div className="space-y-3">
+                  <div className="grid grid-cols-2 gap-3">
+                    <div>
+                      <label className="block text-sm text-bambu-gray mb-1">Turn On at</label>
+                      <input
+                        type="time"
+                        value={scheduleOnTime}
+                        onChange={(e) => setScheduleOnTime(e.target.value)}
+                        className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                      />
+                    </div>
+                    <div>
+                      <label className="block text-sm text-bambu-gray mb-1">Turn Off at</label>
+                      <input
+                        type="time"
+                        value={scheduleOffTime}
+                        onChange={(e) => setScheduleOffTime(e.target.value)}
+                        className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                      />
+                    </div>
                   </div>
+                  <p className="text-xs text-bambu-gray">
+                    Automatically turn the plug on/off at these times daily. Leave empty to skip that action.
+                  </p>
                 </div>
-                <p className="text-xs text-bambu-gray">
-                  Automatically turn the plug on/off at these times daily. Leave empty to skip that action.
-                </p>
-              </div>
-            )}
-          </div>
+              )}
+            </div>
+          )}
 
           {/* Switchbar Visibility */}
           <div className="border-t border-bambu-dark-tertiary pt-4">

+ 33 - 14
frontend/src/components/EditArchiveModal.tsx

@@ -68,30 +68,49 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
     queryFn: () => api.getProjects(),
   });
 
-  // Get all archives to extract existing tags if not provided
-  const { data: archives } = useQuery({
-    queryKey: ['archives'],
-    queryFn: () => api.getArchives(undefined, 1000, 0),
+  // Fetch all tags using the dedicated API
+  const { data: tagsData } = useQuery({
+    queryKey: ['tags'],
+    queryFn: api.getTags,
     enabled: existingTags.length === 0,
   });
 
-  // Extract unique tags from all archives
+  // Use existing tags prop if provided, otherwise use fetched tags
   const allTags = existingTags.length > 0
     ? existingTags
-    : [...new Set(
-        archives?.flatMap(a => a.tags?.split(',').map(t => t.trim()) || []).filter(Boolean) || []
-      )].sort();
+    : (tagsData?.map(t => t.name) || []);
 
   // Get current tags as array
   const currentTags = tags.split(',').map(t => t.trim()).filter(Boolean);
 
-  // Filter suggestions based on what's not already added
-  const tagSuggestions = allTags.filter(t => !currentTags.includes(t));
+  // Get the text being typed after the last comma (for autocomplete filtering)
+  const currentInput = tags.includes(',')
+    ? tags.substring(tags.lastIndexOf(',') + 1).trim().toLowerCase()
+    : tags.trim().toLowerCase();
 
-  // Add a tag
+  // Filter suggestions: not already added AND matches current input (if any)
+  const tagSuggestions = allTags.filter(t =>
+    !currentTags.includes(t) &&
+    (currentInput === '' || t.toLowerCase().includes(currentInput))
+  );
+
+  // Add a tag (replaces any partial input with the selected tag)
   const addTag = (tag: string) => {
-    if (!currentTags.includes(tag)) {
-      const newTags = [...currentTags, tag].join(', ');
+    // If there's partial input being typed, replace it with the selected tag
+    // Otherwise, just append the tag
+    let baseTags: string[];
+    if (currentInput && !allTags.includes(currentInput)) {
+      // User is typing a partial tag - replace it with the selected one
+      baseTags = tags.includes(',')
+        ? tags.substring(0, tags.lastIndexOf(',')).split(',').map(t => t.trim()).filter(Boolean)
+        : [];
+    } else {
+      // No partial input or input is already a complete tag - append
+      baseTags = currentTags;
+    }
+
+    if (!baseTags.includes(tag)) {
+      const newTags = [...baseTags, tag].join(', ');
       setTags(newTags);
     }
     // Clear any pending blur timeout to prevent hiding suggestions
@@ -342,7 +361,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
               {showTagSuggestions && tagSuggestions.length > 0 && (
                 <div className="absolute top-full left-0 right-0 mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-10 max-h-40 overflow-y-auto">
                   <div className="p-2 text-xs text-bambu-gray border-b border-bambu-dark-tertiary">
-                    Existing tags (click to add)
+                    {currentInput ? `Matching "${currentInput}"` : 'Existing tags'} (click to add)
                   </div>
                   <div className="p-2 flex flex-wrap gap-1.5">
                     {tagSuggestions.map((tag) => (

+ 126 - 71
frontend/src/components/SmartPlugCard.tsx

@@ -1,6 +1,6 @@
 import { useState } from 'react';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
-import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2, Bell, Calendar, LayoutGrid, ExternalLink, Home } from 'lucide-react';
+import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2, Bell, Calendar, LayoutGrid, ExternalLink, Home, Radio, Eye } from 'lucide-react';
 import { api } from '../api/client';
 import type { SmartPlug, SmartPlugUpdate } from '../api/client';
 import { Card, CardContent } from './Card';
@@ -92,7 +92,9 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
   });
 
   const isOn = status?.state === 'ON';
-  const isReachable = status?.reachable ?? false;
+  // For MQTT plugs, consider reachable if we have power data (even if backend says not reachable)
+  const hasMqttData = plug.plug_type === 'mqtt' && (status?.energy?.power !== null && status?.energy?.power !== undefined);
+  const isReachable = (status?.reachable ?? false) || hasMqttData;
   const isPending = controlMutation.isPending;
 
   // Generate admin URL with auto-login credentials (Tasmota only)
@@ -113,27 +115,49 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
       <Card className="relative">
         <CardContent className="p-4">
           {/* Header Row */}
-          <div className="flex items-start justify-between mb-3">
+          <div className="flex items-start justify-between gap-2 mb-3">
             <div className="flex items-center gap-3 min-w-0 flex-1">
-              <div className={`p-2 rounded-lg flex-shrink-0 ${isReachable ? (isOn ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20'}`}>
-                {plug.plug_type === 'homeassistant' ? (
+              <div className={`p-2 rounded-lg flex-shrink-0 ${
+                plug.plug_type === 'mqtt'
+                  ? (isReachable ? 'bg-teal-500/20' : 'bg-red-500/20')
+                  : (isReachable ? (isOn ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20')
+              }`}>
+                {plug.plug_type === 'mqtt' ? (
+                  <Radio className={`w-5 h-5 ${isReachable ? 'text-teal-400' : 'text-red-400'}`} />
+                ) : plug.plug_type === 'homeassistant' ? (
                   <Home className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
                 ) : (
                   <Plug className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
                 )}
               </div>
               <div className="min-w-0">
-                <h3 className="font-medium text-white truncate" title={plug.name}>{plug.name}</h3>
-                <p className="text-sm text-bambu-gray truncate">
-                  {plug.plug_type === 'homeassistant' ? plug.ha_entity_id : plug.ip_address}
+                <h3 className="font-medium text-white truncate">{plug.name}</h3>
+                <p
+                  className="text-sm text-bambu-gray truncate"
+                  title={plug.plug_type === 'mqtt' ? plug.mqtt_topic ?? undefined : plug.plug_type === 'homeassistant' ? plug.ha_entity_id ?? undefined : plug.ip_address ?? undefined}
+                >
+                  {plug.plug_type === 'mqtt' ? plug.mqtt_topic : plug.plug_type === 'homeassistant' ? plug.ha_entity_id : plug.ip_address}
                 </p>
               </div>
             </div>
 
             {/* Status indicator */}
-            <div className="flex flex-col items-end gap-1">
+            <div className="flex flex-col items-end gap-1 flex-shrink-0">
               {statusLoading ? (
                 <Loader2 className="w-4 h-4 text-bambu-gray animate-spin" />
+              ) : plug.plug_type === 'mqtt' ? (
+                /* MQTT plugs - show badge and checkmark when receiving data */
+                <div className="flex items-center gap-1.5 text-sm whitespace-nowrap">
+                  <span className="px-1.5 py-0.5 bg-teal-500/20 text-teal-400 text-[10px] font-medium rounded flex-shrink-0">MQTT</span>
+                  {isReachable && <span className="text-status-ok">✓</span>}
+                </div>
+              ) : plug.plug_type === 'homeassistant' ? (
+                <div className="flex items-center gap-1 text-sm">
+                  <span className="px-1 py-0.5 bg-blue-500/20 text-blue-400 text-[10px] font-medium rounded">HA</span>
+                  <span className={isReachable ? (isOn ? 'text-status-ok' : 'text-bambu-gray') : 'text-status-error'}>
+                    {isReachable ? (status?.state || '?') : 'Offline'}
+                  </span>
+                </div>
               ) : isReachable ? (
                 <div className="flex items-center gap-1 text-sm">
                   <Wifi className="w-4 h-4 text-status-ok" />
@@ -170,8 +194,14 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
           )}
 
           {/* Feature Badges */}
-          {(plug.power_alert_enabled || plug.schedule_enabled) && (
+          {(plug.power_alert_enabled || plug.schedule_enabled || plug.plug_type === 'mqtt') && (
             <div className="flex flex-wrap gap-1.5 mb-3">
+              {plug.plug_type === 'mqtt' && (
+                <span className="flex items-center gap-1 px-2 py-0.5 bg-teal-500/20 text-teal-400 text-xs rounded-full">
+                  <Eye className="w-3 h-3" />
+                  Monitor Only
+                </span>
+              )}
               {plug.power_alert_enabled && (
                 <span className="flex items-center gap-1 px-2 py-0.5 bg-yellow-500/20 text-yellow-400 text-xs rounded-full">
                   <Bell className="w-3 h-3" />
@@ -191,29 +221,49 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
             </div>
           )}
 
-          {/* Quick Controls */}
-          <div className="flex gap-2 mb-3">
-            <Button
-              size="sm"
-              variant={isOn ? 'primary' : 'secondary'}
-              disabled={!isReachable || isPending}
-              onClick={() => setShowPowerOnConfirm(true)}
-              className="flex-1"
-            >
-              {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
-              On
-            </Button>
-            <Button
-              size="sm"
-              variant={!isOn ? 'primary' : 'secondary'}
-              disabled={!isReachable || isPending}
-              onClick={() => setShowPowerOffConfirm(true)}
-              className="flex-1"
-            >
-              {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
-              Off
-            </Button>
-          </div>
+          {/* Quick Controls - hidden for MQTT plugs (monitor-only) */}
+          {plug.plug_type !== 'mqtt' && (
+            <div className="flex gap-2 mb-3">
+              <Button
+                size="sm"
+                variant={isOn ? 'primary' : 'secondary'}
+                disabled={!isReachable || isPending}
+                onClick={() => setShowPowerOnConfirm(true)}
+                className="flex-1"
+              >
+                {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
+                On
+              </Button>
+              <Button
+                size="sm"
+                variant={!isOn ? 'primary' : 'secondary'}
+                disabled={!isReachable || isPending}
+                onClick={() => setShowPowerOffConfirm(true)}
+                className="flex-1"
+              >
+                {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
+                Off
+              </Button>
+            </div>
+          )}
+
+          {/* Energy display for MQTT plugs */}
+          {plug.plug_type === 'mqtt' && status?.energy && (
+            <div className="flex gap-2 mb-3 px-3 py-2 bg-bambu-dark rounded-lg">
+              {status.energy.power !== null && status.energy.power !== undefined && (
+                <div className="flex-1 text-center">
+                  <p className="text-lg font-semibold text-white">{Math.round(status.energy.power)}W</p>
+                  <p className="text-xs text-bambu-gray">Power</p>
+                </div>
+              )}
+              {status.energy.today !== null && status.energy.today !== undefined && (
+                <div className="flex-1 text-center border-l border-bambu-dark-tertiary">
+                  <p className="text-lg font-semibold text-white">{status.energy.today.toFixed(2)}</p>
+                  <p className="text-xs text-bambu-gray">kWh Today</p>
+                </div>
+              )}
+            </div>
+          )}
 
           {/* Toggle Settings Panel */}
           <button
@@ -222,7 +272,7 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
           >
             <span className="flex items-center gap-2">
               <Settings2 className="w-4 h-4" />
-              Automation Settings
+              {plug.plug_type === 'mqtt' ? 'Settings' : 'Automation Settings'}
             </span>
             <span>{isExpanded ? '-' : '+'}</span>
           </button>
@@ -250,45 +300,48 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
                 </label>
               </div>
 
-              {/* Enabled Toggle */}
-              <div className="flex items-center justify-between">
-                <div>
-                  <p className="text-sm text-white">Enabled</p>
-                  <p className="text-xs text-bambu-gray">Enable automation for this plug</p>
-                </div>
-                <label className="relative inline-flex items-center cursor-pointer">
-                  <input
-                    type="checkbox"
-                    checked={plug.enabled}
-                    onChange={(e) => updateMutation.mutate({ enabled: e.target.checked })}
-                    className="sr-only peer"
-                  />
-                  <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
-                </label>
-              </div>
+              {/* Automation controls - only for controllable plugs (not MQTT) */}
+              {plug.plug_type !== 'mqtt' && (
+                <>
+                  {/* Enabled Toggle */}
+                  <div className="flex items-center justify-between">
+                    <div>
+                      <p className="text-sm text-white">Enabled</p>
+                      <p className="text-xs text-bambu-gray">Enable automation for this plug</p>
+                    </div>
+                    <label className="relative inline-flex items-center cursor-pointer">
+                      <input
+                        type="checkbox"
+                        checked={plug.enabled}
+                        onChange={(e) => updateMutation.mutate({ enabled: e.target.checked })}
+                        className="sr-only peer"
+                      />
+                      <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
+                    </label>
+                  </div>
 
-              {/* Auto On */}
-              <div className="flex items-center justify-between">
-                <div>
-                  <p className="text-sm text-white">Auto On</p>
-                  <p className="text-xs text-bambu-gray">Turn on when print starts</p>
-                </div>
-                <label className="relative inline-flex items-center cursor-pointer">
-                  <input
-                    type="checkbox"
-                    checked={plug.auto_on}
-                    onChange={(e) => updateMutation.mutate({ auto_on: e.target.checked })}
-                    className="sr-only peer"
-                  />
-                  <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
-                </label>
-              </div>
+                  {/* Auto On */}
+                  <div className="flex items-center justify-between">
+                    <div>
+                      <p className="text-sm text-white">Auto On</p>
+                      <p className="text-xs text-bambu-gray">Turn on when print starts</p>
+                    </div>
+                    <label className="relative inline-flex items-center cursor-pointer">
+                      <input
+                        type="checkbox"
+                        checked={plug.auto_on}
+                        onChange={(e) => updateMutation.mutate({ auto_on: e.target.checked })}
+                        className="sr-only peer"
+                      />
+                      <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
+                    </label>
+                  </div>
 
-              {/* Auto Off */}
-              <div className="flex items-center justify-between">
-                <div>
-                  <p className="text-sm text-white">Auto Off</p>
-                  <p className="text-xs text-bambu-gray">Turn off when print completes (one-shot)</p>
+                  {/* Auto Off */}
+                  <div className="flex items-center justify-between">
+                    <div>
+                      <p className="text-sm text-white">Auto Off</p>
+                      <p className="text-xs text-bambu-gray">Turn off when print completes (one-shot)</p>
                 </div>
                 <label className="relative inline-flex items-center cursor-pointer">
                   <input
@@ -360,6 +413,8 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
                   )}
                 </div>
               )}
+                </>
+              )}
 
               {/* Action Buttons */}
               <div className="flex gap-2 pt-2">

+ 60 - 30
frontend/src/components/SwitchbarPopover.tsx

@@ -1,6 +1,6 @@
 import { useState } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Plug, Power, PowerOff, Loader2, Wifi, WifiOff, Zap } from 'lucide-react';
+import { Plug, Power, PowerOff, Loader2, Wifi, WifiOff, Zap, Radio, Eye } from 'lucide-react';
 import { api } from '../api/client';
 import type { SmartPlug } from '../api/client';
 import { ConfirmModal } from './ConfirmModal';
@@ -29,8 +29,11 @@ function SwitchItem({ plug }: { plug: SmartPlug }) {
   });
 
   const isOn = status?.state === 'ON';
-  const isReachable = status?.reachable ?? false;
+  // For MQTT plugs, consider reachable if we have power data
+  const hasMqttData = plug.plug_type === 'mqtt' && (status?.energy?.power !== null && status?.energy?.power !== undefined);
+  const isReachable = (status?.reachable ?? false) || hasMqttData;
   const isPending = controlMutation.isPending;
+  const isMqtt = plug.plug_type === 'mqtt';
 
   const handleConfirm = () => {
     if (confirmAction) {
@@ -43,14 +46,38 @@ function SwitchItem({ plug }: { plug: SmartPlug }) {
     <>
       <div className="flex items-center justify-between py-2 px-3 hover:bg-bambu-dark-tertiary rounded-lg transition-colors">
         <div className="flex items-center gap-2">
-          <div className={`p-1.5 rounded ${isReachable ? (isOn ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20'}`}>
-            <Plug className={`w-4 h-4 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
+          <div className={`p-1.5 rounded ${
+            isMqtt
+              ? (isReachable ? 'bg-teal-500/20' : 'bg-red-500/20')
+              : (isReachable ? (isOn ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20')
+          }`}>
+            {isMqtt ? (
+              <Radio className={`w-4 h-4 ${isReachable ? 'text-teal-400' : 'text-red-400'}`} />
+            ) : (
+              <Plug className={`w-4 h-4 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
+            )}
           </div>
           <div>
             <p className="text-sm text-white font-medium">{plug.name}</p>
             <div className="flex items-center gap-1 text-xs">
               {statusLoading ? (
                 <Loader2 className="w-3 h-3 text-bambu-gray animate-spin" />
+              ) : isMqtt ? (
+                /* MQTT plugs show power and monitor-only indicator */
+                isReachable ? (
+                  <>
+                    <Zap className="w-3 h-3 text-teal-400" />
+                    <span className="text-teal-400">{Math.round(status?.energy?.power ?? 0)}W</span>
+                    <span className="text-bambu-gray mx-1">|</span>
+                    <Eye className="w-3 h-3 text-bambu-gray" />
+                    <span className="text-bambu-gray">Monitor</span>
+                  </>
+                ) : (
+                  <>
+                    <WifiOff className="w-3 h-3 text-status-error" />
+                    <span className="text-status-error">Waiting</span>
+                  </>
+                )
               ) : isReachable ? (
                 <>
                   <Wifi className="w-3 h-3 text-status-ok" />
@@ -73,32 +100,35 @@ function SwitchItem({ plug }: { plug: SmartPlug }) {
           </div>
         </div>
 
-        <div className="flex gap-1">
-          <button
-            onClick={() => setConfirmAction('on')}
-            disabled={!isReachable || isPending}
-            className={`p-1.5 rounded transition-colors ${
-              isOn
-                ? 'bg-bambu-green text-white'
-                : 'bg-bambu-dark text-bambu-gray hover:text-white'
-            } disabled:opacity-50 disabled:cursor-not-allowed`}
-            title="Turn On"
-          >
-            {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
-          </button>
-          <button
-            onClick={() => setConfirmAction('off')}
-            disabled={!isReachable || isPending}
-            className={`p-1.5 rounded transition-colors ${
-              !isOn && isReachable
-                ? 'bg-bambu-dark-tertiary text-white'
-                : 'bg-bambu-dark text-bambu-gray hover:text-white'
-            } disabled:opacity-50 disabled:cursor-not-allowed`}
-            title="Turn Off"
-          >
-            {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
-          </button>
-        </div>
+        {/* Hide on/off buttons for MQTT plugs (monitor-only) */}
+        {!isMqtt && (
+          <div className="flex gap-1">
+            <button
+              onClick={() => setConfirmAction('on')}
+              disabled={!isReachable || isPending}
+              className={`p-1.5 rounded transition-colors ${
+                isOn
+                  ? 'bg-bambu-green text-white'
+                  : 'bg-bambu-dark text-bambu-gray hover:text-white'
+              } disabled:opacity-50 disabled:cursor-not-allowed`}
+              title="Turn On"
+            >
+              {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
+            </button>
+            <button
+              onClick={() => setConfirmAction('off')}
+              disabled={!isReachable || isPending}
+              className={`p-1.5 rounded transition-colors ${
+                !isOn && isReachable
+                  ? 'bg-bambu-dark-tertiary text-white'
+                  : 'bg-bambu-dark text-bambu-gray hover:text-white'
+              } disabled:opacity-50 disabled:cursor-not-allowed`}
+              title="Turn Off"
+            >
+              {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
+            </button>
+          </div>
+        )}
       </div>
 
       {confirmAction && (

+ 294 - 0
frontend/src/components/TagManagementModal.tsx

@@ -0,0 +1,294 @@
+import { useState, useEffect } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { X, Tag, Pencil, Trash2, Loader2, Search, Check, AlertTriangle } from 'lucide-react';
+import { api } from '../api/client';
+import type { TagInfo } from '../api/client';
+import { Card, CardContent } from './Card';
+import { Button } from './Button';
+import { useToast } from '../contexts/ToastContext';
+
+interface TagManagementModalProps {
+  onClose: () => void;
+}
+
+export function TagManagementModal({ onClose }: TagManagementModalProps) {
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const [search, setSearch] = useState('');
+  const [editingTag, setEditingTag] = useState<string | null>(null);
+  const [editValue, setEditValue] = useState('');
+  const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
+  const [sortBy, setSortBy] = useState<'count' | 'name'>('count');
+
+  // Close on Escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') {
+        if (editingTag) {
+          setEditingTag(null);
+        } else if (deleteConfirm) {
+          setDeleteConfirm(null);
+        } else {
+          onClose();
+        }
+      }
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose, editingTag, deleteConfirm]);
+
+  const { data: tags, isLoading } = useQuery({
+    queryKey: ['tags'],
+    queryFn: api.getTags,
+  });
+
+  const renameMutation = useMutation({
+    mutationFn: ({ oldName, newName }: { oldName: string; newName: string }) =>
+      api.renameTag(oldName, newName),
+    onSuccess: (data, { oldName, newName }) => {
+      queryClient.invalidateQueries({ queryKey: ['tags'] });
+      queryClient.invalidateQueries({ queryKey: ['archives'] });
+      showToast(`Renamed "${oldName}" to "${newName}" in ${data.affected} archive${data.affected !== 1 ? 's' : ''}`);
+      setEditingTag(null);
+    },
+    onError: (error: Error) => {
+      showToast(error.message || 'Failed to rename tag', 'error');
+    },
+  });
+
+  const deleteMutation = useMutation({
+    mutationFn: (name: string) => api.deleteTag(name),
+    onSuccess: (data, name) => {
+      queryClient.invalidateQueries({ queryKey: ['tags'] });
+      queryClient.invalidateQueries({ queryKey: ['archives'] });
+      showToast(`Deleted "${name}" from ${data.affected} archive${data.affected !== 1 ? 's' : ''}`);
+      setDeleteConfirm(null);
+    },
+    onError: (error: Error) => {
+      showToast(error.message || 'Failed to delete tag', 'error');
+    },
+  });
+
+  const startEdit = (tag: TagInfo) => {
+    setEditingTag(tag.name);
+    setEditValue(tag.name);
+    setDeleteConfirm(null);
+  };
+
+  const cancelEdit = () => {
+    setEditingTag(null);
+    setEditValue('');
+  };
+
+  const submitEdit = () => {
+    if (!editingTag || !editValue.trim()) return;
+    const newName = editValue.trim();
+    if (newName === editingTag) {
+      cancelEdit();
+      return;
+    }
+    renameMutation.mutate({ oldName: editingTag, newName });
+  };
+
+  const handleEditKeyDown = (e: React.KeyboardEvent) => {
+    if (e.key === 'Enter') {
+      e.preventDefault();
+      submitEdit();
+    } else if (e.key === 'Escape') {
+      e.preventDefault();
+      cancelEdit();
+    }
+  };
+
+  const confirmDelete = (name: string) => {
+    setDeleteConfirm(name);
+    setEditingTag(null);
+  };
+
+  const executeDelete = () => {
+    if (deleteConfirm) {
+      deleteMutation.mutate(deleteConfirm);
+    }
+  };
+
+  // Filter and sort tags
+  const filteredTags = tags
+    ?.filter(t => t.name.toLowerCase().includes(search.toLowerCase()))
+    .sort((a, b) => {
+      if (sortBy === 'count') {
+        return b.count - a.count || a.name.localeCompare(b.name);
+      }
+      return a.name.localeCompare(b.name);
+    });
+
+  const totalUsage = tags?.reduce((sum, t) => sum + t.count, 0) || 0;
+
+  return (
+    <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
+      <Card className="w-full max-w-lg max-h-[80vh] flex flex-col">
+        <CardContent className="p-0 flex flex-col min-h-0">
+          {/* Header */}
+          <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
+            <div className="flex items-center gap-2">
+              <Tag className="w-5 h-5 text-bambu-green" />
+              <h2 className="text-xl font-semibold text-white">Manage Tags</h2>
+            </div>
+            <button
+              onClick={onClose}
+              className="text-bambu-gray hover:text-white transition-colors"
+            >
+              <X className="w-5 h-5" />
+            </button>
+          </div>
+
+          {/* Search and sort */}
+          <div className="p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
+            <div className="flex gap-2">
+              <div className="relative flex-1">
+                <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
+                <input
+                  type="text"
+                  placeholder="Search tags..."
+                  className="w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
+                  value={search}
+                  onChange={(e) => setSearch(e.target.value)}
+                />
+              </div>
+              <select
+                className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
+                value={sortBy}
+                onChange={(e) => setSortBy(e.target.value as 'count' | 'name')}
+              >
+                <option value="count">Sort by Count</option>
+                <option value="name">Sort by Name</option>
+              </select>
+            </div>
+            {tags && (
+              <p className="text-xs text-bambu-gray mt-2">
+                {tags.length} tag{tags.length !== 1 ? 's' : ''} across {totalUsage} usage{totalUsage !== 1 ? 's' : ''}
+              </p>
+            )}
+          </div>
+
+          {/* Tags list */}
+          <div className="flex-1 overflow-y-auto min-h-0 p-4">
+            {isLoading ? (
+              <div className="flex items-center justify-center py-8">
+                <Loader2 className="w-6 h-6 animate-spin text-bambu-gray" />
+              </div>
+            ) : !filteredTags?.length ? (
+              <div className="text-center py-8 text-bambu-gray">
+                {search ? 'No tags match your search' : 'No tags found'}
+              </div>
+            ) : (
+              <div className="space-y-2">
+                {filteredTags.map((tag) => (
+                  <div
+                    key={tag.name}
+                    className="flex items-center gap-2 p-2 rounded-lg bg-bambu-dark hover:bg-bambu-dark-tertiary transition-colors group"
+                  >
+                    {editingTag === tag.name ? (
+                      // Edit mode
+                      <div className="flex-1 flex items-center gap-2">
+                        <input
+                          type="text"
+                          className="flex-1 px-2 py-1 bg-bambu-dark-tertiary border border-bambu-green rounded text-white text-sm focus:outline-none"
+                          value={editValue}
+                          onChange={(e) => setEditValue(e.target.value)}
+                          onKeyDown={handleEditKeyDown}
+                          autoFocus
+                        />
+                        <Button
+                          size="sm"
+                          variant="primary"
+                          onClick={submitEdit}
+                          disabled={!editValue.trim() || renameMutation.isPending}
+                          className="p-1.5"
+                        >
+                          {renameMutation.isPending ? (
+                            <Loader2 className="w-4 h-4 animate-spin" />
+                          ) : (
+                            <Check className="w-4 h-4" />
+                          )}
+                        </Button>
+                        <Button
+                          size="sm"
+                          variant="ghost"
+                          onClick={cancelEdit}
+                          className="p-1.5"
+                        >
+                          <X className="w-4 h-4" />
+                        </Button>
+                      </div>
+                    ) : deleteConfirm === tag.name ? (
+                      // Delete confirmation
+                      <div className="flex-1 flex items-center gap-2">
+                        <AlertTriangle className="w-4 h-4 text-yellow-400 flex-shrink-0" />
+                        <span className="text-sm text-bambu-gray-light flex-1">
+                          Delete "{tag.name}" from {tag.count} archive{tag.count !== 1 ? 's' : ''}?
+                        </span>
+                        <Button
+                          size="sm"
+                          variant="danger"
+                          onClick={executeDelete}
+                          disabled={deleteMutation.isPending}
+                          className="p-1.5"
+                        >
+                          {deleteMutation.isPending ? (
+                            <Loader2 className="w-4 h-4 animate-spin" />
+                          ) : (
+                            <Trash2 className="w-4 h-4" />
+                          )}
+                        </Button>
+                        <Button
+                          size="sm"
+                          variant="ghost"
+                          onClick={() => setDeleteConfirm(null)}
+                          className="p-1.5"
+                        >
+                          <X className="w-4 h-4" />
+                        </Button>
+                      </div>
+                    ) : (
+                      // Normal display
+                      <>
+                        <Tag className="w-4 h-4 text-bambu-gray flex-shrink-0" />
+                        <span className="text-white flex-1 truncate">{tag.name}</span>
+                        <span className="px-2 py-0.5 rounded-full bg-bambu-dark-tertiary text-bambu-gray text-xs">
+                          {tag.count}
+                        </span>
+                        <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
+                          <button
+                            onClick={() => startEdit(tag)}
+                            className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
+                            title="Rename tag"
+                          >
+                            <Pencil className="w-4 h-4" />
+                          </button>
+                          <button
+                            onClick={() => confirmDelete(tag.name)}
+                            className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors"
+                            title="Delete tag"
+                          >
+                            <Trash2 className="w-4 h-4" />
+                          </button>
+                        </div>
+                      </>
+                    )}
+                  </div>
+                ))}
+              </div>
+            )}
+          </div>
+
+          {/* Footer */}
+          <div className="flex gap-3 p-4 border-t border-bambu-dark-tertiary flex-shrink-0">
+            <Button variant="secondary" onClick={onClose} className="flex-1">
+              Close
+            </Button>
+          </div>
+        </CardContent>
+      </Card>
+    </div>
+  );
+}

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

@@ -43,6 +43,7 @@ import {
   FolderKanban,
   ChevronLeft,
   ChevronRight,
+  Settings,
 } from 'lucide-react';
 import { api } from '../api/client';
 import { openInSlicer } from '../utils/slicer';
@@ -66,6 +67,7 @@ import { ProjectPageModal } from '../components/ProjectPageModal';
 import { TimelapseViewer } from '../components/TimelapseViewer';
 import { CompareArchivesModal } from '../components/CompareArchivesModal';
 import { PendingUploadsPanel } from '../components/PendingUploadsPanel';
+import { TagManagementModal } from '../components/TagManagementModal';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 
@@ -2076,6 +2078,7 @@ export function ArchivesPage() {
   const [showExportMenu, setShowExportMenu] = useState(false);
   const [isExporting, setIsExporting] = useState(false);
   const [showCompareModal, setShowCompareModal] = useState(false);
+  const [showTagManagement, setShowTagManagement] = useState(false);
   const [highlightedArchiveId, setHighlightedArchiveId] = useState<number | null>(null);
 
   // Clear highlight after 5 seconds and scroll to highlighted element
@@ -2719,6 +2722,13 @@ export function ArchivesPage() {
                     </option>
                   ))}
                 </select>
+                <button
+                  onClick={() => setShowTagManagement(true)}
+                  className="p-2 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-green transition-colors"
+                  title="Manage Tags"
+                >
+                  <Settings className="w-4 h-4" />
+                </button>
               </div>
             )}
             <div className="flex items-center gap-2 flex-shrink-0">
@@ -2947,6 +2957,11 @@ export function ArchivesPage() {
           }}
         />
       )}
+
+      {/* Tag Management Modal */}
+      {showTagManagement && (
+        <TagManagementModal onClose={() => setShowTagManagement(false)} />
+      )}
     </div>
   );
 }

+ 37 - 0
frontend/src/pages/PrintersPage.tsx

@@ -1095,6 +1095,12 @@ function PrinterCard({
     queryFn: () => api.getSmartPlugByPrinter(printer.id),
   });
 
+  // Fetch script plugs for this printer (for multi-device control)
+  const { data: scriptPlugs } = useQuery({
+    queryKey: ['scriptPlugsByPrinter', printer.id],
+    queryFn: () => api.getScriptPlugsByPrinter(printer.id),
+  });
+
   // Fetch smart plug status if plug exists (faster refresh for energy monitoring)
   const { data: plugStatus } = useQuery({
     queryKey: ['smartPlugStatus', smartPlug?.id],
@@ -1158,6 +1164,15 @@ function PrinterCard({
     },
   });
 
+  // Run script mutation
+  const runScriptMutation = useMutation({
+    mutationFn: (scriptId: number) => api.controlSmartPlug(scriptId, 'on'),
+    onSuccess: () => {
+      showToast('Script triggered');
+    },
+    onError: (error: Error) => showToast(error.message || 'Failed to run script', 'error'),
+  });
+
   // Print control mutations
   const stopPrintMutation = useMutation({
     mutationFn: () => api.stopPrint(printer.id),
@@ -2720,6 +2735,28 @@ function PrinterCard({
                 </button>
               </div>
             </div>
+
+            {/* Script buttons row */}
+            {scriptPlugs && scriptPlugs.length > 0 && (
+              <div className="flex items-center gap-2 mt-2 pt-2 border-t border-bambu-dark-tertiary/50">
+                <Play className="w-3.5 h-3.5 text-blue-400 flex-shrink-0" />
+                <span className="text-xs text-bambu-gray">Scripts:</span>
+                <div className="flex flex-wrap gap-1">
+                  {scriptPlugs.map(script => (
+                    <button
+                      key={script.id}
+                      onClick={() => runScriptMutation.mutate(script.id)}
+                      disabled={runScriptMutation.isPending}
+                      title={`Run ${script.ha_entity_id}`}
+                      className="px-2 py-0.5 text-xs bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 rounded transition-colors flex items-center gap-1"
+                    >
+                      <Play className="w-2.5 h-2.5" />
+                      {script.name}
+                    </button>
+                  ))}
+                </div>
+              </div>
+            )}
           </div>
         )}
 

+ 11 - 7
frontend/src/pages/SettingsPage.tsx

@@ -175,13 +175,17 @@ export function SettingsPage() {
       let totalLifetime = 0;
       let reachableCount = 0;
 
-      for (const { status } of statuses) {
-        if (status?.reachable && status.energy) {
+      for (const { plug, status } of statuses) {
+        // For MQTT plugs, consider reachable if we have power data
+        const hasMqttData = plug.plug_type === 'mqtt' && (status?.energy?.power != null);
+        const isReachable = (status?.reachable || hasMqttData) && status?.energy;
+
+        if (isReachable) {
           reachableCount++;
-          if (status.energy.power != null) totalPower += status.energy.power;
-          if (status.energy.today != null) totalToday += status.energy.today;
-          if (status.energy.yesterday != null) totalYesterday += status.energy.yesterday;
-          if (status.energy.total != null) totalLifetime += status.energy.total;
+          if (status.energy?.power != null) totalPower += status.energy.power;
+          if (status.energy?.today != null) totalToday += status.energy.today;
+          if (status.energy?.yesterday != null) totalYesterday += status.energy.yesterday;
+          if (status.energy?.total != null) totalLifetime += status.energy.total;
         }
       }
 
@@ -1950,7 +1954,7 @@ export function SettingsPage() {
             </CardHeader>
             <CardContent className="space-y-4">
               <p className="text-sm text-bambu-gray">
-                Connect to Home Assistant to control smart plugs via HA's REST API. Supports switch, light, and input_boolean entities.
+                Connect to Home Assistant to control smart plugs via HA's REST API. Supports switch, light, input_boolean, and script entities.
               </p>
 
               <div className="flex items-center justify-between">

+ 308 - 0
frontend/src/pages/StreamOverlayPage.tsx

@@ -0,0 +1,308 @@
+import { useEffect, useMemo, useState } from 'react';
+import { useParams, useSearchParams } from 'react-router-dom';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { Layers, Clock, Timer, Printer } from 'lucide-react';
+import { api } from '../api/client';
+import type { PrinterStatus } from '../api/client';
+
+type OverlaySize = 'small' | 'medium' | 'large';
+
+interface OverlayConfig {
+  size: OverlaySize;
+  showProgress: boolean;
+  showLayers: boolean;
+  showEta: boolean;
+  showFilename: boolean;
+  showStatus: boolean;
+  showPrinter: boolean;
+}
+
+function parseConfig(params: URLSearchParams): OverlayConfig {
+  const show = params.get('show')?.split(',') || ['progress', 'layers', 'eta', 'filename', 'status'];
+
+  return {
+    size: (params.get('size') as OverlaySize) || 'medium',
+    showProgress: show.includes('progress'),
+    showLayers: show.includes('layers'),
+    showEta: show.includes('eta'),
+    showFilename: show.includes('filename'),
+    showStatus: show.includes('status'),
+    showPrinter: show.includes('printer'),
+  };
+}
+
+function formatTime(seconds: number): string {
+  const hours = Math.floor(seconds / 3600);
+  const minutes = Math.floor((seconds % 3600) / 60);
+  return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
+}
+
+function formatETA(remainingMinutes: number): string {
+  const now = new Date();
+  const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000);
+  const today = new Date();
+  today.setHours(0, 0, 0, 0);
+  const etaDay = new Date(eta);
+  etaDay.setHours(0, 0, 0, 0);
+
+  const timeStr = eta.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+
+  if (etaDay.getTime() === today.getTime()) {
+    return timeStr;
+  } else if (etaDay.getTime() === today.getTime() + 86400000) {
+    return `Tomorrow ${timeStr}`;
+  } else {
+    return eta.toLocaleDateString([], { weekday: 'short' }) + ' ' + timeStr;
+  }
+}
+
+function getStatusText(status: PrinterStatus): string {
+  if (status.stg_cur_name) return status.stg_cur_name;
+
+  switch (status.state) {
+    case 'RUNNING': return 'Printing';
+    case 'PAUSE': return 'Paused';
+    case 'FINISH': return 'Finished';
+    case 'FAILED': return 'Failed';
+    case 'IDLE': return 'Idle';
+    default: return status.state || 'Unknown';
+  }
+}
+
+function getSizeClasses(size: OverlaySize) {
+  switch (size) {
+    case 'small':
+      return {
+        container: 'p-3',
+        text: 'text-sm',
+        textLarge: 'text-lg',
+        progressHeight: 'h-2',
+        icon: 'w-3 h-3',
+        gap: 'gap-2',
+        logoHeight: 'h-12',
+      };
+    case 'large':
+      return {
+        container: 'p-6',
+        text: 'text-xl',
+        textLarge: 'text-3xl',
+        progressHeight: 'h-4',
+        icon: 'w-6 h-6',
+        gap: 'gap-4',
+        logoHeight: 'h-24',
+      };
+    case 'medium':
+    default:
+      return {
+        container: 'p-4',
+        text: 'text-base',
+        textLarge: 'text-xl',
+        progressHeight: 'h-3',
+        icon: 'w-4 h-4',
+        gap: 'gap-3',
+        logoHeight: 'h-16',
+      };
+  }
+}
+
+export function StreamOverlayPage() {
+  const { printerId } = useParams<{ printerId: string }>();
+  const [searchParams] = useSearchParams();
+  const queryClient = useQueryClient();
+  const id = parseInt(printerId || '0', 10);
+  const [imageKey, setImageKey] = useState(Date.now());
+
+  const config = useMemo(() => parseConfig(searchParams), [searchParams]);
+  const sizes = getSizeClasses(config.size);
+
+  // Fetch printer info
+  const { data: printer } = useQuery({
+    queryKey: ['printer', id],
+    queryFn: () => api.getPrinter(id),
+    enabled: id > 0,
+  });
+
+  // Fetch printer status with polling
+  const { data: status } = useQuery({
+    queryKey: ['printerStatus', id],
+    queryFn: () => api.getPrinterStatus(id),
+    enabled: id > 0,
+    refetchInterval: 2000,
+  });
+
+  // WebSocket for real-time updates
+  useEffect(() => {
+    if (!id) return;
+
+    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+    const wsUrl = `${protocol}//${window.location.host}/api/v1/ws`;
+    const ws = new WebSocket(wsUrl);
+
+    ws.onmessage = (event) => {
+      try {
+        const data = JSON.parse(event.data);
+        if (data.type === 'printer_status' && data.printer_id === id) {
+          queryClient.setQueryData(['printerStatus', id], data.status);
+        }
+      } catch {
+        // Ignore parse errors
+      }
+    };
+
+    ws.onerror = () => {
+      // WebSocket error - polling will continue as fallback
+    };
+
+    return () => {
+      ws.close();
+    };
+  }, [id, queryClient]);
+
+  // Update document title
+  useEffect(() => {
+    document.title = printer ? `${printer.name} - Stream Overlay` : 'Stream Overlay';
+    return () => {
+      document.title = 'Bambuddy';
+    };
+  }, [printer]);
+
+  // Refresh stream on error
+  const handleStreamError = () => {
+    setTimeout(() => {
+      setImageKey(Date.now());
+    }, 3000);
+  };
+
+  if (!id) {
+    return (
+      <div className="min-h-screen bg-black flex items-center justify-center">
+        <p className="text-white">Invalid printer ID</p>
+      </div>
+    );
+  }
+
+  if (!status) {
+    return (
+      <div className="min-h-screen bg-black flex items-center justify-center">
+        <p className="text-gray-400">Loading...</p>
+      </div>
+    );
+  }
+
+  const isPrinting = status.state === 'RUNNING' || status.state === 'PAUSE';
+  const progress = status.progress || 0;
+  const streamUrl = `/api/v1/printers/${id}/camera/stream?fps=10&t=${imageKey}`;
+
+  return (
+    <div className="min-h-screen bg-black relative overflow-hidden">
+      {/* Camera feed - fullscreen background */}
+      <img
+        key={imageKey}
+        src={streamUrl}
+        alt="Camera stream"
+        className="absolute inset-0 w-full h-full object-contain"
+        onError={handleStreamError}
+      />
+
+      {/* Bambuddy logo - top right */}
+      <a
+        href="https://github.com/maziggy/bambuddy"
+        target="_blank"
+        rel="noopener noreferrer"
+        className="absolute top-4 right-4 z-10"
+      >
+        <img
+          src="/img/bambuddy_logo_dark_transparent.png"
+          alt="Bambuddy"
+          className={`${sizes.logoHeight} object-contain drop-shadow-lg hover:scale-105 transition-transform`}
+        />
+      </a>
+
+      {/* Status overlay - bottom */}
+      <div className="absolute bottom-0 left-0 right-0 z-10 bg-gradient-to-t from-black/80 via-black/60 to-transparent">
+        <div className={`${sizes.container}`}>
+          {/* Printer name */}
+          {config.showPrinter && printer && (
+            <div className={`flex items-center ${sizes.gap} mb-2`}>
+              <Printer className={`${sizes.icon} text-white/70`} />
+              <span className={`${sizes.text} text-white font-medium`}>{printer.name}</span>
+            </div>
+          )}
+
+          {/* Filename */}
+          {config.showFilename && status.current_print && (
+            <div className={`${sizes.textLarge} text-white font-semibold mb-2 truncate drop-shadow-md`}>
+              {status.current_print.replace(/\.gcode\.3mf$|\.3mf$|\.gcode$/i, '')}
+            </div>
+          )}
+
+          {/* Status text */}
+          {config.showStatus && (
+            <div className={`${sizes.text} text-white/70 mb-2`}>
+              {getStatusText(status)}
+            </div>
+          )}
+
+          {/* Progress bar */}
+          {config.showProgress && isPrinting && (
+            <div className="mb-3">
+              <div className={`flex items-center justify-between mb-1 ${sizes.text}`}>
+                <span className="text-white/70">Progress</span>
+                <span className="text-white font-bold">{Math.round(progress)}%</span>
+              </div>
+              <div className={`w-full bg-white/20 rounded-full ${sizes.progressHeight}`}>
+                <div
+                  className={`bg-bambu-green ${sizes.progressHeight} rounded-full transition-all duration-500`}
+                  style={{ width: `${progress}%` }}
+                />
+              </div>
+            </div>
+          )}
+
+          {/* Stats row */}
+          {isPrinting && (config.showLayers || config.showEta) && (
+            <div className={`flex items-center ${sizes.gap} flex-wrap`}>
+              {/* Layers */}
+              {config.showLayers && status.layer_num != null && status.total_layers != null && status.total_layers > 0 && (
+                <div className={`flex items-center ${sizes.gap} text-white/70`}>
+                  <Layers className={sizes.icon} />
+                  <span className={sizes.text}>
+                    <span className="text-white">{status.layer_num}</span>
+                    <span className="mx-1">/</span>
+                    <span>{status.total_layers}</span>
+                  </span>
+                </div>
+              )}
+
+              {/* Remaining time */}
+              {config.showEta && status.remaining_time != null && status.remaining_time > 0 && (
+                <>
+                  <div className={`flex items-center ${sizes.gap} text-white/70`}>
+                    <Timer className={sizes.icon} />
+                    <span className={`${sizes.text} text-white`}>
+                      {formatTime(status.remaining_time * 60)}
+                    </span>
+                  </div>
+
+                  <div className={`flex items-center ${sizes.gap} text-white/70`}>
+                    <Clock className={sizes.icon} />
+                    <span className={`${sizes.text} text-white`}>
+                      ETA {formatETA(status.remaining_time)}
+                    </span>
+                  </div>
+                </>
+              )}
+            </div>
+          )}
+
+          {/* Idle state */}
+          {!isPrinting && (
+            <div className={`${sizes.text} text-white/70 py-2`}>
+              {status.connected ? 'Printer is idle' : 'Printer offline'}
+            </div>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}

+ 234 - 0
install/README.md

@@ -0,0 +1,234 @@
+# BamBuddy Installation Scripts
+
+Interactive installation scripts for BamBuddy with support for both native and Docker deployments.
+
+## Quick Start
+
+### Docker Installation (Recommended)
+
+**Linux/macOS:**
+```bash
+curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/docker-install.sh | bash
+```
+
+### Native Installation
+
+**Linux/macOS:**
+```bash
+curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/install.sh | bash
+```
+
+---
+
+## Scripts Overview
+
+| Script | Platform | Method |
+|--------|----------|--------|
+| `install.sh` | Linux, macOS | Native (Python venv) |
+| `docker-install.sh` | Linux, macOS | Docker |
+
+---
+
+## Native Installation Scripts
+
+### `install.sh` (Linux/macOS)
+
+Installs BamBuddy with Python virtual environment and optional systemd/launchd service.
+
+**Supported Systems:**
+- Debian/Ubuntu (apt)
+- RHEL/Fedora/CentOS (dnf/yum)
+- Arch Linux (pacman)
+- openSUSE (zypper)
+- macOS (Homebrew)
+
+**Options:**
+```
+--path PATH        Installation directory (default: /opt/bambuddy)
+--port PORT        Port to listen on (default: 8000)
+--tz TIMEZONE      Timezone (default: system timezone)
+--data-dir PATH    Data directory (default: INSTALL_PATH/data)
+--log-dir PATH     Log directory (default: INSTALL_PATH/logs)
+--debug            Enable debug mode
+--log-level LEVEL  Log level: DEBUG, INFO, WARNING, ERROR (default: INFO)
+--no-service       Skip systemd/launchd service setup
+--yes, -y          Non-interactive mode, accept defaults
+```
+
+**Examples:**
+```bash
+# Interactive installation
+./install.sh
+
+# Unattended with custom settings
+./install.sh --path /srv/bambuddy --port 3000 --tz America/New_York --yes
+
+# Minimal unattended
+./install.sh -y
+
+# Skip service setup
+./install.sh --no-service -y
+```
+
+---
+
+## Docker Installation Scripts
+
+### `docker-install.sh` (Linux/macOS)
+
+Installs BamBuddy using Docker containers.
+
+**Options:**
+```
+--path PATH        Installation directory (default: ~/bambuddy)
+--port PORT        Port to expose (default: 8000)
+--tz TIMEZONE      Timezone (default: system timezone)
+--build            Build from source instead of using pre-built image
+--yes, -y          Non-interactive mode, accept defaults
+```
+
+**Examples:**
+```bash
+# Interactive installation
+./docker-install.sh
+
+# Unattended with custom settings
+./docker-install.sh --path /srv/bambuddy --port 3000 --tz Europe/Berlin --yes
+
+# Build from source
+./docker-install.sh --build --yes
+```
+
+---
+
+## Configuration Options
+
+All scripts support these configuration options:
+
+| Option | Description | Default |
+|--------|-------------|---------|
+| Install Path | Where BamBuddy is installed | `/opt/bambuddy` (Linux/Docker) |
+| Port | HTTP port for web interface | `8000` |
+| Timezone | Server timezone | System timezone or `UTC` |
+| Data Directory | Database and archives | `INSTALL_PATH/data` |
+| Log Directory | Application logs | `INSTALL_PATH/logs` |
+| Debug Mode | Enable verbose logging | `false` |
+| Log Level | INFO, WARNING, ERROR, DEBUG | `INFO` |
+
+---
+
+## Post-Installation
+
+### Accessing BamBuddy
+
+After installation, open your browser to:
+```
+http://localhost:8000
+```
+
+Or use the port you specified during installation.
+
+### Service Management
+
+**Linux (systemd):**
+```bash
+sudo systemctl status bambuddy    # Check status
+sudo systemctl start bambuddy     # Start
+sudo systemctl stop bambuddy      # Stop
+sudo systemctl restart bambuddy   # Restart
+sudo journalctl -u bambuddy -f    # View logs
+```
+
+**macOS (launchd):**
+```bash
+launchctl list | grep bambuddy                              # Check status
+launchctl load ~/Library/LaunchAgents/com.bambuddy.app.plist    # Start
+launchctl unload ~/Library/LaunchAgents/com.bambuddy.app.plist  # Stop
+```
+
+**Docker:**
+```bash
+docker compose ps           # Check status
+docker compose up -d        # Start
+docker compose down         # Stop
+docker compose restart      # Restart
+docker compose logs -f      # View logs
+```
+
+### Updating
+
+**Native installation:**
+```bash
+cd /opt/bambuddy
+git pull
+source venv/bin/activate
+pip install -r requirements.txt
+cd frontend && npm ci && npm run build
+sudo systemctl restart bambuddy  # Linux
+```
+
+**Docker (pre-built image):**
+```bash
+cd ~/bambuddy
+docker compose pull
+docker compose up -d
+```
+
+**Docker (from source):**
+```bash
+cd ~/bambuddy
+git pull
+docker compose up -d --build
+```
+
+---
+
+## Troubleshooting
+
+### Permission Denied (Linux)
+Run with `sudo` or ensure your user has appropriate permissions:
+```bash
+sudo ./install.sh
+```
+
+### Docker: Printer Discovery Not Working
+Docker Desktop for macOS doesn't support host networking. Add printers manually by IP address in the BamBuddy web interface.
+
+### Service Won't Start
+Check logs for errors:
+```bash
+# Linux
+sudo journalctl -u bambuddy -n 50
+
+# Docker
+docker compose logs bambuddy
+```
+
+### Port Already in Use
+Choose a different port during installation or stop the conflicting service:
+```bash
+# Find what's using port 8000
+sudo lsof -i :8000  # Linux/macOS
+```
+
+---
+
+## Requirements
+
+### Native Installation
+- Python 3.10+ (automatically installed if missing)
+- Node.js 18+ (automatically installed if missing)
+- Git (automatically installed if missing)
+- ~500MB disk space
+
+### Docker Installation
+- Docker Engine 20+ or Docker Desktop
+- ~1GB disk space (includes image)
+
+---
+
+## Support
+
+- **Documentation:** https://wiki.bambuddy.cool
+- **Discord:** https://discord.gg/aFS3ZfScHM
+- **Issues:** https://github.com/maziggy/bambuddy/issues

+ 541 - 0
install/docker-install.sh

@@ -0,0 +1,541 @@
+#!/usr/bin/env bash
+#
+# BamBuddy Docker Installation Script
+# Supports: Linux (all distros), macOS
+#
+# Usage:
+#   Interactive:  curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/docker-install.sh | bash
+#   Unattended:   ./docker-install.sh --path /opt/bambuddy --port 8000 --yes
+#
+# Options:
+#   --path PATH        Installation directory (default: /opt/bambuddy)
+#   --port PORT        Port to expose (default: 8000)
+#   --bind ADDRESS     Bind address: 0.0.0.0 (network) or 127.0.0.1 (local only)
+#   --tz TIMEZONE      Timezone (default: system timezone or UTC)
+#   --build            Build from source instead of using pre-built image
+#   --yes, -y          Non-interactive mode, accept defaults
+#   --help, -h         Show this help message
+#
+
+set -e
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+CYAN='\033[0;36m'
+NC='\033[0m' # No Color
+BOLD='\033[1m'
+
+# Default values
+DEFAULT_INSTALL_PATH="/opt/bambuddy"
+DEFAULT_PORT="8000"
+DEFAULT_BIND_ADDRESS="0.0.0.0"
+
+# Script variables
+INSTALL_PATH=""
+PORT=""
+BIND_ADDRESS=""
+TIMEZONE=""
+BUILD_FROM_SOURCE="false"
+NON_INTERACTIVE="false"
+OS_TYPE=""
+DOCKER_CMD=""
+
+# -----------------------------------------------------------------------------
+# Helper Functions
+# -----------------------------------------------------------------------------
+
+print_banner() {
+    echo -e "${CYAN}"
+    echo "╔════════════════════════════════════════════════════════╗"
+    echo "║                                                        ║"
+    echo "║   ____                  _               _     _        ║"
+    echo "║  | __ )  __ _ _ __ ___ | |__  _   _  __| | __| |_   _  ║"
+    echo "║  |  _ \\ / _\` | '_ \` _ \\| '_ \\| | | |/ _\` |/ _\` | | | | ║"
+    echo "║  | |_) | (_| | | | | | | |_) | |_| | (_| | (_| | |_| | ║"
+    echo "║  |____/ \\__,_|_| |_| |_|_.__/ \\__,_|\\__,_|\\__,_|\\__, | ║"
+    echo "║                                                 |___/  ║"
+    echo "║                                                        ║"
+    echo "║            Docker Installation Script                  ║"
+    echo "║                                                        ║"
+    echo "╚════════════════════════════════════════════════════════╝"
+    echo -e "${NC}"
+}
+
+log_info() {
+    echo -e "${BLUE}[INFO]${NC} $1"
+}
+
+log_success() {
+    echo -e "${GREEN}[OK]${NC} $1"
+}
+
+log_warn() {
+    echo -e "${YELLOW}[WARN]${NC} $1"
+}
+
+log_error() {
+    echo -e "${RED}[ERROR]${NC} $1"
+}
+
+prompt() {
+    local prompt_text="$1"
+    local default_value="$2"
+    local var_name="$3"
+
+    if [[ "$NON_INTERACTIVE" == "true" ]]; then
+        eval "$var_name=\"$default_value\""
+        return
+    fi
+
+    if [[ -n "$default_value" ]]; then
+        echo -en "${BOLD}$prompt_text${NC} [${CYAN}$default_value${NC}]: "
+    else
+        echo -en "${BOLD}$prompt_text${NC}: "
+    fi
+
+    read -r input
+    if [[ -z "$input" ]]; then
+        eval "$var_name=\"$default_value\""
+    else
+        eval "$var_name=\"$input\""
+    fi
+}
+
+prompt_yes_no() {
+    local prompt_text="$1"
+    local default="$2"  # y or n
+
+    if [[ "$NON_INTERACTIVE" == "true" ]]; then
+        [[ "$default" == "y" ]] && return 0 || return 1
+    fi
+
+    local yn_hint="[y/n]"
+    [[ "$default" == "y" ]] && yn_hint="[Y/n]"
+    [[ "$default" == "n" ]] && yn_hint="[y/N]"
+
+    while true; do
+        echo -en "${BOLD}$prompt_text${NC} $yn_hint: "
+        read -r yn
+        [[ -z "$yn" ]] && yn="$default"
+        case "$yn" in
+            [Yy]* ) return 0;;
+            [Nn]* ) return 1;;
+            * ) echo "Please answer yes or no.";;
+        esac
+    done
+}
+
+show_help() {
+    echo "BamBuddy Docker Installation Script"
+    echo ""
+    echo "Usage: $0 [OPTIONS]"
+    echo ""
+    echo "Options:"
+    echo "  --path PATH        Installation directory (default: /opt/bambuddy)"
+    echo "  --port PORT        Port to expose (default: 8000)"
+    echo "  --bind ADDRESS     Bind address: 0.0.0.0 (network) or 127.0.0.1 (local only)"
+    echo "  --tz TIMEZONE      Timezone (default: system timezone or UTC)"
+    echo "  --build            Build from source instead of using pre-built image"
+    echo "  --yes, -y          Non-interactive mode, accept defaults"
+    echo "  --help, -h         Show this help message"
+    echo ""
+    echo "Examples:"
+    echo "  Interactive installation:"
+    echo "    ./docker-install.sh"
+    echo ""
+    echo "  Unattended installation with custom settings:"
+    echo "    ./docker-install.sh --path /srv/bambuddy --port 3000 --tz America/New_York --yes"
+    echo ""
+    echo "  Build from source:"
+    echo "    ./docker-install.sh --build --yes"
+    exit 0
+}
+
+# -----------------------------------------------------------------------------
+# System Detection
+# -----------------------------------------------------------------------------
+
+detect_os() {
+    if [[ "$OSTYPE" == "darwin"* ]]; then
+        OS_TYPE="macos"
+        return
+    fi
+
+    if [[ -f /etc/os-release ]]; then
+        OS_TYPE="linux"
+    else
+        log_error "Cannot detect operating system"
+        exit 1
+    fi
+}
+
+detect_docker() {
+    # Check for docker compose (v2) or docker-compose (v1)
+    if docker compose version &>/dev/null 2>&1; then
+        DOCKER_CMD="docker compose"
+        log_success "Found Docker Compose v2"
+        return 0
+    elif docker-compose --version &>/dev/null 2>&1; then
+        DOCKER_CMD="docker-compose"
+        log_success "Found Docker Compose v1"
+        return 0
+    fi
+    return 1
+}
+
+detect_timezone() {
+    if [[ -n "$TIMEZONE" ]]; then
+        return 0
+    fi
+
+    # Try to get system timezone (with error handling for set -e)
+    TIMEZONE=""
+    if [[ -f /etc/timezone ]]; then
+        TIMEZONE=$(cat /etc/timezone 2>/dev/null) || true
+    fi
+
+    if [[ -z "$TIMEZONE" ]] && [[ -L /etc/localtime ]]; then
+        TIMEZONE=$(readlink /etc/localtime 2>/dev/null | sed 's|.*/zoneinfo/||') || true
+    fi
+
+    if [[ -z "$TIMEZONE" ]] && command -v timedatectl &>/dev/null; then
+        TIMEZONE=$(timedatectl show --property=Timezone --value 2>/dev/null) || true
+    fi
+
+    # Default to UTC if not found (use if/then to avoid set -e issue with &&)
+    if [[ -z "$TIMEZONE" ]]; then
+        TIMEZONE="UTC"
+    fi
+    return 0
+}
+
+# -----------------------------------------------------------------------------
+# Installation Functions
+# -----------------------------------------------------------------------------
+
+install_docker() {
+    log_info "Docker not found, installing..."
+
+    case "$OS_TYPE" in
+        linux)
+            # Use Docker's convenience script
+            curl -fsSL https://get.docker.com | sh
+
+            # Add current user to docker group
+            if [[ -n "$SUDO_USER" ]]; then
+                sudo usermod -aG docker "$SUDO_USER"
+                log_warn "Added $SUDO_USER to docker group. You may need to log out and back in."
+            else
+                sudo usermod -aG docker "$USER"
+                log_warn "Added $USER to docker group. You may need to log out and back in."
+            fi
+
+            # Start Docker service
+            sudo systemctl enable docker
+            sudo systemctl start docker
+            ;;
+        macos)
+            log_error "Docker Desktop not found."
+            log_error "Please install Docker Desktop for Mac from: https://www.docker.com/products/docker-desktop"
+            exit 1
+            ;;
+    esac
+
+    log_success "Docker installed"
+}
+
+create_install_dir() {
+    log_info "Creating installation directory..."
+
+    mkdir -p "$INSTALL_PATH"
+    cd "$INSTALL_PATH"
+
+    log_success "Directory created: $INSTALL_PATH"
+}
+
+download_compose_file() {
+    log_info "Downloading docker-compose.yml..."
+
+    if [[ "$BUILD_FROM_SOURCE" == "true" ]]; then
+        # Clone the full repo for building
+        if [[ -d ".git" ]]; then
+            log_info "Existing repository found, updating..."
+            git fetch origin
+            git reset --hard origin/main
+        else
+            git clone https://github.com/maziggy/bambuddy.git .
+        fi
+    else
+        # Just download the compose file
+        curl -fsSL -o docker-compose.yml \
+            https://raw.githubusercontent.com/maziggy/bambuddy/main/docker-compose.yml
+    fi
+
+    log_success "docker-compose.yml ready"
+}
+
+create_env_file() {
+    log_info "Creating environment configuration..."
+
+    cat > .env << EOF
+# BamBuddy Docker Configuration
+# Generated by docker-install.sh on $(date)
+
+# Port BamBuddy runs on
+PORT=$PORT
+
+# Timezone
+TZ=$TIMEZONE
+EOF
+
+    log_success "Environment file created"
+}
+
+customize_compose() {
+    # Detect if we need to disable host networking (macOS/Windows in Docker Desktop)
+    if [[ "$OS_TYPE" == "macos" ]]; then
+        log_warn "Docker Desktop detected. Host networking is not supported."
+        log_info "Modifying docker-compose.yml for port mapping..."
+
+        # Create a modified compose file for macOS
+        if [[ -f docker-compose.yml ]]; then
+            # Comment out network_mode: host and uncomment ports section
+            sed -i.bak \
+                -e 's/^[[:space:]]*network_mode: host/#    network_mode: host/' \
+                -e 's/^[[:space:]]*#ports:/    ports:/' \
+                -e 's/^[[:space:]]*#[[:space:]]*- "\${PORT:-8000}:8000"/      - "\${PORT:-8000}:8000"/' \
+                docker-compose.yml
+
+            log_warn "Printer discovery may not work. Add printers manually by IP address."
+        fi
+    fi
+}
+
+start_container() {
+    log_info "Starting BamBuddy..."
+
+    if [[ "$BUILD_FROM_SOURCE" == "true" ]]; then
+        $DOCKER_CMD up -d --build
+    else
+        $DOCKER_CMD up -d
+    fi
+
+    # Wait for container to start
+    log_info "Waiting for container to start..."
+    local max_attempts=15
+    local attempt=0
+
+    while [[ $attempt -lt $max_attempts ]]; do
+        # Check if container is running (Up)
+        if $DOCKER_CMD ps | grep -q "Up"; then
+            log_success "BamBuddy container is running"
+            return 0
+        fi
+
+        # Check if container failed
+        if $DOCKER_CMD ps -a | grep -q "Exited"; then
+            log_error "Container failed to start"
+            log_info "Check logs with: $DOCKER_CMD logs bambuddy"
+            return 1
+        fi
+
+        sleep 2
+        ((attempt++))
+    done
+
+    log_warn "Container may still be starting. Check with: $DOCKER_CMD ps"
+}
+
+# -----------------------------------------------------------------------------
+# Main Installation Flow
+# -----------------------------------------------------------------------------
+
+parse_args() {
+    while [[ $# -gt 0 ]]; do
+        case "$1" in
+            --path)
+                INSTALL_PATH="$2"
+                shift 2
+                ;;
+            --port)
+                PORT="$2"
+                shift 2
+                ;;
+            --bind)
+                BIND_ADDRESS="$2"
+                shift 2
+                ;;
+            --tz)
+                TIMEZONE="$2"
+                shift 2
+                ;;
+            --build)
+                BUILD_FROM_SOURCE="true"
+                shift
+                ;;
+            --yes|-y)
+                NON_INTERACTIVE="true"
+                shift
+                ;;
+            --help|-h)
+                show_help
+                ;;
+            *)
+                log_error "Unknown option: $1"
+                show_help
+                ;;
+        esac
+    done
+}
+
+gather_config() {
+    echo ""
+    echo -e "${BOLD}Installation Configuration${NC}"
+    echo -e "${CYAN}─────────────────────────────────────────${NC}"
+    echo ""
+
+    # Installation path
+    [[ -z "$INSTALL_PATH" ]] && prompt "Installation directory" "$DEFAULT_INSTALL_PATH" INSTALL_PATH
+
+    # Port
+    [[ -z "$PORT" ]] && prompt "Port to expose" "$DEFAULT_PORT" PORT
+
+    # Bind address
+    if [[ -z "$BIND_ADDRESS" ]]; then
+        echo ""
+        echo "Network access:"
+        echo "  0.0.0.0   - Accessible from other devices on your network (recommended)"
+        echo "  127.0.0.1 - Only accessible from this machine"
+        prompt "Bind address" "$DEFAULT_BIND_ADDRESS" BIND_ADDRESS
+    fi
+
+    # Timezone
+    detect_timezone
+    prompt "Timezone" "$TIMEZONE" TIMEZONE
+
+    # Build from source?
+    if [[ "$BUILD_FROM_SOURCE" != "true" ]] && [[ "$NON_INTERACTIVE" != "true" ]]; then
+        if prompt_yes_no "Build from source? (No = use pre-built image)" "n"; then
+            BUILD_FROM_SOURCE="true"
+        fi
+    fi
+
+    # Confirm
+    echo ""
+    echo -e "${BOLD}Installation Summary${NC}"
+    echo -e "${CYAN}─────────────────────────────────────────${NC}"
+    echo -e "  Install path:  ${GREEN}$INSTALL_PATH${NC}"
+    echo -e "  Port:          ${GREEN}$PORT${NC}"
+    echo -e "  Bind address:  ${GREEN}$BIND_ADDRESS${NC}"
+    echo -e "  Timezone:      ${GREEN}$TIMEZONE${NC}"
+    echo -e "  Build source:  ${GREEN}$BUILD_FROM_SOURCE${NC}"
+    echo ""
+
+    if ! prompt_yes_no "Proceed with installation?" "y"; then
+        echo "Installation cancelled."
+        exit 0
+    fi
+}
+
+main() {
+    parse_args "$@"
+    print_banner
+
+    # Check if running via pipe (curl | bash) - interactive mode won't work
+    if [[ ! -t 0 ]] && [[ "$NON_INTERACTIVE" != "true" ]]; then
+        log_error "Interactive mode requires a terminal."
+        log_info "When using 'curl | bash', you must use non-interactive mode:"
+        echo ""
+        echo "    curl -fsSL URL | bash -s -- --yes"
+        echo ""
+        log_info "Or download and run directly:"
+        echo ""
+        echo "    curl -fsSL URL -o docker-install.sh && chmod +x docker-install.sh && ./docker-install.sh"
+        echo ""
+        exit 1
+    fi
+
+    # Detect system
+    log_info "Detecting system..."
+    detect_os
+    log_success "Detected: $OS_TYPE"
+
+    # Check for Docker
+    if ! command -v docker &>/dev/null; then
+        install_docker
+    fi
+
+    if ! detect_docker; then
+        log_error "Docker Compose not found. Please install Docker Compose."
+        exit 1
+    fi
+
+    # Check if Docker daemon is running
+    if ! docker info &>/dev/null; then
+        log_error "Docker daemon is not running. Please start Docker and try again."
+        exit 1
+    fi
+
+    # Gather configuration
+    gather_config
+
+    # Install steps
+    echo ""
+    echo -e "${BOLD}Starting Installation${NC}"
+    echo -e "${CYAN}─────────────────────────────────────────${NC}"
+    echo ""
+
+    create_install_dir
+    download_compose_file
+    create_env_file
+    customize_compose
+    start_container
+
+    # Done!
+    echo ""
+    echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}"
+    echo -e "${GREEN}║                                                              ║${NC}"
+    echo -e "${GREEN}║              Installation Complete!                          ║${NC}"
+    echo -e "${GREEN}║                                                              ║${NC}"
+    echo -e "${GREEN}╚══════════════════════════════════════════════════════════════╝${NC}"
+    echo ""
+    # Show appropriate URL based on bind address
+    if [[ "$BIND_ADDRESS" == "0.0.0.0" ]]; then
+        local ip_addr
+        ip_addr=$(hostname -I 2>/dev/null | awk '{print $1}') || ip_addr="<your-ip>"
+        echo -e "  ${BOLD}Access BamBuddy:${NC}  ${CYAN}http://localhost:$PORT${NC}"
+        echo -e "                    ${CYAN}http://$ip_addr:$PORT${NC} (from other devices)"
+    else
+        echo -e "  ${BOLD}Access BamBuddy:${NC}  ${CYAN}http://localhost:$PORT${NC}"
+    fi
+    echo ""
+    echo -e "  ${BOLD}Manage container:${NC}"
+    echo -e "    Status:  cd $INSTALL_PATH && $DOCKER_CMD ps"
+    echo -e "    Logs:    cd $INSTALL_PATH && $DOCKER_CMD logs -f bambuddy"
+    echo -e "    Stop:    cd $INSTALL_PATH && $DOCKER_CMD down"
+    echo -e "    Start:   cd $INSTALL_PATH && $DOCKER_CMD up -d"
+    echo -e "    Restart: cd $INSTALL_PATH && $DOCKER_CMD restart"
+    echo ""
+    echo -e "  ${BOLD}Update BamBuddy:${NC}"
+    if [[ "$BUILD_FROM_SOURCE" == "true" ]]; then
+        echo -e "    cd $INSTALL_PATH && git pull && $DOCKER_CMD up -d --build"
+    else
+        echo -e "    cd $INSTALL_PATH && $DOCKER_CMD pull && $DOCKER_CMD up -d"
+    fi
+    echo ""
+    echo -e "  ${BOLD}Data location:${NC}  Docker volumes (bambuddy_data, bambuddy_logs)"
+    echo ""
+    echo -e "  ${BOLD}Documentation:${NC}  ${CYAN}https://wiki.bambuddy.cool${NC}"
+    echo ""
+
+    if [[ "$OS_TYPE" == "macos" ]]; then
+        echo -e "  ${YELLOW}Note:${NC} Printer discovery may not work with Docker Desktop."
+        echo -e "        Add printers manually using their IP address."
+        echo ""
+    fi
+}
+
+main "$@"

+ 883 - 0
install/install.sh

@@ -0,0 +1,883 @@
+#!/usr/bin/env bash
+#
+# BamBuddy Native Installation Script
+# Supports: Debian/Ubuntu, RHEL/Fedora/CentOS, Arch Linux, macOS
+#
+# Usage:
+#   Interactive:  curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/install.sh | bash
+#   Unattended:   ./install.sh --path /opt/bambuddy --port 8000 --yes
+#
+# Options:
+#   --path PATH        Installation directory (default: /opt/bambuddy)
+#   --port PORT        Port to listen on (default: 8000)
+#   --bind ADDRESS     Bind address: 0.0.0.0 (network) or 127.0.0.1 (local only)
+#   --tz TIMEZONE      Timezone (default: system timezone or UTC)
+#   --data-dir PATH    Data directory (default: INSTALL_PATH/data)
+#   --log-dir PATH     Log directory (default: INSTALL_PATH/logs)
+#   --debug            Enable debug mode
+#   --log-level LEVEL  Log level: DEBUG, INFO, WARNING, ERROR (default: INFO)
+#   --no-service       Skip systemd service setup (Linux only)
+#   --set-system-tz    Set system timezone to match (for unattended installs)
+#   --yes, -y          Non-interactive mode, accept defaults
+#   --help, -h         Show this help message
+#
+
+set -e
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+CYAN='\033[0;36m'
+NC='\033[0m' # No Color
+BOLD='\033[1m'
+
+# Default values
+DEFAULT_INSTALL_PATH="/opt/bambuddy"
+DEFAULT_PORT="8000"
+DEFAULT_BIND_ADDRESS="0.0.0.0"
+DEFAULT_LOG_LEVEL="INFO"
+DEFAULT_DEBUG="false"
+
+# Script variables
+INSTALL_PATH=""
+PORT=""
+BIND_ADDRESS=""
+TIMEZONE=""
+DATA_DIR=""
+LOG_DIR=""
+DEBUG_MODE=""
+LOG_LEVEL=""
+SKIP_SERVICE="false"
+SET_SYSTEM_TZ=""
+NON_INTERACTIVE="false"
+OS_TYPE=""
+PKG_MANAGER=""
+PYTHON_CMD=""
+SERVICE_USER="bambuddy"
+
+# -----------------------------------------------------------------------------
+# Helper Functions
+# -----------------------------------------------------------------------------
+
+print_banner() {
+    echo -e "${CYAN}"
+    echo "╔════════════════════════════════════════════════════════╗"
+    echo "║                                                        ║"
+    echo "║   ____                  _               _     _        ║"
+    echo "║  | __ )  __ _ _ __ ___ | |__  _   _  __| | __| |_   _  ║"
+    echo "║  |  _ \\ / _\` | '_ \` _ \\| '_ \\| | | |/ _\` |/ _\` | | | | ║"
+    echo "║  | |_) | (_| | | | | | | |_) | |_| | (_| | (_| | |_| | ║"
+    echo "║  |____/ \\__,_|_| |_| |_|_.__/ \\__,_|\\__,_|\\__,_|\\__, | ║"
+    echo "║                                                 |___/  ║"
+    echo "║                                                        ║"
+    echo "║            Native Installation Script                  ║"
+    echo "║                                                        ║"
+    echo "╚════════════════════════════════════════════════════════╝"
+    echo -e "${NC}"
+}
+
+log_info() {
+    echo -e "${BLUE}[INFO]${NC} $1"
+}
+
+log_success() {
+    echo -e "${GREEN}[OK]${NC} $1"
+}
+
+log_warn() {
+    echo -e "${YELLOW}[WARN]${NC} $1"
+}
+
+log_error() {
+    echo -e "${RED}[ERROR]${NC} $1"
+}
+
+prompt() {
+    local prompt_text="$1"
+    local default_value="$2"
+    local var_name="$3"
+
+    if [[ "$NON_INTERACTIVE" == "true" ]]; then
+        eval "$var_name=\"$default_value\""
+        return
+    fi
+
+    if [[ -n "$default_value" ]]; then
+        echo -en "${BOLD}$prompt_text${NC} [${CYAN}$default_value${NC}]: "
+    else
+        echo -en "${BOLD}$prompt_text${NC}: "
+    fi
+
+    read -r input
+    if [[ -z "$input" ]]; then
+        eval "$var_name=\"$default_value\""
+    else
+        eval "$var_name=\"$input\""
+    fi
+}
+
+prompt_yes_no() {
+    local prompt_text="$1"
+    local default="$2"  # y or n
+
+    if [[ "$NON_INTERACTIVE" == "true" ]]; then
+        [[ "$default" == "y" ]] && return 0 || return 1
+    fi
+
+    local yn_hint="[y/n]"
+    [[ "$default" == "y" ]] && yn_hint="[Y/n]"
+    [[ "$default" == "n" ]] && yn_hint="[y/N]"
+
+    while true; do
+        echo -en "${BOLD}$prompt_text${NC} $yn_hint: "
+        read -r yn
+        [[ -z "$yn" ]] && yn="$default"
+        case "$yn" in
+            [Yy]* ) return 0;;
+            [Nn]* ) return 1;;
+            * ) echo "Please answer yes or no.";;
+        esac
+    done
+}
+
+show_help() {
+    echo "BamBuddy Native Installation Script"
+    echo ""
+    echo "Usage: $0 [OPTIONS]"
+    echo ""
+    echo "Options:"
+    echo "  --path PATH        Installation directory (default: /opt/bambuddy)"
+    echo "  --port PORT        Port to listen on (default: 8000)"
+    echo "  --bind ADDRESS     Bind address: 0.0.0.0 (network) or 127.0.0.1 (local only)"
+    echo "  --tz TIMEZONE      Timezone (default: system timezone or UTC)"
+    echo "  --data-dir PATH    Data directory (default: INSTALL_PATH/data)"
+    echo "  --log-dir PATH     Log directory (default: INSTALL_PATH/logs)"
+    echo "  --debug            Enable debug mode"
+    echo "  --log-level LEVEL  Log level: DEBUG, INFO, WARNING, ERROR (default: INFO)"
+    echo "  --no-service       Skip systemd service setup (Linux only)"
+    echo "  --set-system-tz    Set system timezone to match (for unattended installs)"
+    echo "  --yes, -y          Non-interactive mode, accept defaults"
+    echo "  --help, -h         Show this help message"
+    echo ""
+    echo "Examples:"
+    echo "  Interactive installation:"
+    echo "    ./install.sh"
+    echo ""
+    echo "  Unattended installation with custom settings:"
+    echo "    ./install.sh --path /srv/bambuddy --port 3000 --tz America/New_York --yes"
+    echo ""
+    echo "  Minimal unattended installation:"
+    echo "    ./install.sh -y"
+    exit 0
+}
+
+# -----------------------------------------------------------------------------
+# System Detection
+# -----------------------------------------------------------------------------
+
+detect_os() {
+    if [[ "$OSTYPE" == "darwin"* ]]; then
+        OS_TYPE="macos"
+        PKG_MANAGER="brew"
+        return
+    fi
+
+    if [[ -f /etc/os-release ]]; then
+        . /etc/os-release
+        case "$ID" in
+            ubuntu|debian|raspbian|linuxmint|pop)
+                OS_TYPE="debian"
+                PKG_MANAGER="apt"
+                ;;
+            fedora|rhel|centos|rocky|almalinux|ol)
+                OS_TYPE="rhel"
+                if command -v dnf &>/dev/null; then
+                    PKG_MANAGER="dnf"
+                else
+                    PKG_MANAGER="yum"
+                fi
+                ;;
+            arch|manjaro|endeavouros)
+                OS_TYPE="arch"
+                PKG_MANAGER="pacman"
+                ;;
+            opensuse*|sles)
+                OS_TYPE="suse"
+                PKG_MANAGER="zypper"
+                ;;
+            *)
+                log_error "Unsupported Linux distribution: $ID"
+                exit 1
+                ;;
+        esac
+    else
+        log_error "Cannot detect operating system"
+        exit 1
+    fi
+}
+
+detect_python() {
+    # Try python3 first, then python
+    if command -v python3 &>/dev/null; then
+        PYTHON_CMD="python3"
+    elif command -v python &>/dev/null; then
+        local version
+        version=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1)
+        if [[ "$version" -ge 3 ]]; then
+            PYTHON_CMD="python"
+        fi
+    fi
+
+    if [[ -z "$PYTHON_CMD" ]]; then
+        return 1
+    fi
+
+    # Check version >= 3.10
+    local version
+    version=$($PYTHON_CMD -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
+    local major minor
+    major=$(echo "$version" | cut -d'.' -f1)
+    minor=$(echo "$version" | cut -d'.' -f2)
+
+    if [[ "$major" -lt 3 ]] || { [[ "$major" -eq 3 ]] && [[ "$minor" -lt 10 ]]; }; then
+        log_warn "Python $version found, but 3.10+ is required"
+        return 1
+    fi
+
+    log_success "Found Python $version"
+    return 0
+}
+
+detect_timezone() {
+    if [[ -n "$TIMEZONE" ]]; then
+        return 0
+    fi
+
+    # Try to get system timezone (with error handling for set -e)
+    TIMEZONE=""
+    if [[ -f /etc/timezone ]]; then
+        TIMEZONE=$(cat /etc/timezone 2>/dev/null) || true
+    fi
+
+    if [[ -z "$TIMEZONE" ]] && [[ -L /etc/localtime ]]; then
+        TIMEZONE=$(readlink /etc/localtime 2>/dev/null | sed 's|.*/zoneinfo/||') || true
+    fi
+
+    if [[ -z "$TIMEZONE" ]] && command -v timedatectl &>/dev/null; then
+        TIMEZONE=$(timedatectl show --property=Timezone --value 2>/dev/null) || true
+    fi
+
+    # Default to UTC if not found (use if/then to avoid set -e issue with &&)
+    if [[ -z "$TIMEZONE" ]]; then
+        TIMEZONE="UTC"
+    fi
+    return 0
+}
+
+# -----------------------------------------------------------------------------
+# Package Installation
+# -----------------------------------------------------------------------------
+
+install_dependencies() {
+    log_info "Installing system dependencies..."
+
+    case "$PKG_MANAGER" in
+        apt)
+            sudo apt-get update
+            sudo apt-get install -y python3 python3-pip python3-venv git curl ffmpeg
+            ;;
+        dnf|yum)
+            sudo $PKG_MANAGER install -y python3 python3-pip git curl ffmpeg
+            ;;
+        pacman)
+            sudo pacman -Sy --noconfirm python python-pip git curl ffmpeg
+            ;;
+        zypper)
+            sudo zypper install -y python3 python3-pip git curl ffmpeg
+            ;;
+        brew)
+            # Check if Homebrew is installed
+            if ! command -v brew &>/dev/null; then
+                log_error "Homebrew not found. Please install it first: https://brew.sh"
+                exit 1
+            fi
+            brew install python git curl ffmpeg
+            ;;
+    esac
+
+    log_success "System dependencies installed"
+}
+
+# -----------------------------------------------------------------------------
+# Installation Steps
+# -----------------------------------------------------------------------------
+
+create_user() {
+    if [[ "$OS_TYPE" == "macos" ]]; then
+        return  # Skip user creation on macOS
+    fi
+
+    if id "$SERVICE_USER" &>/dev/null; then
+        log_info "User '$SERVICE_USER' already exists"
+        return
+    fi
+
+    log_info "Creating service user '$SERVICE_USER'..."
+    sudo useradd --system --shell /usr/sbin/nologin --home-dir "$INSTALL_PATH" "$SERVICE_USER"
+    log_success "Service user created"
+}
+
+download_bambuddy() {
+    log_info "Downloading BamBuddy..."
+
+    if [[ -d "$INSTALL_PATH/.git" ]]; then
+        log_info "Existing installation found, updating..."
+        # Add safe.directory to avoid "dubious ownership" error when running as root
+        git config --global --add safe.directory "$INSTALL_PATH" 2>/dev/null || true
+        cd "$INSTALL_PATH"
+        git fetch origin
+        git reset --hard origin/main
+        # Ensure correct ownership after update
+        sudo chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_PATH" 2>/dev/null || true
+    else
+        sudo mkdir -p "$INSTALL_PATH"
+        sudo chown "$SERVICE_USER:$SERVICE_USER" "$INSTALL_PATH" 2>/dev/null || true
+        git clone https://github.com/maziggy/bambuddy.git "$INSTALL_PATH"
+        sudo chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_PATH" 2>/dev/null || true
+    fi
+
+    log_success "BamBuddy downloaded to $INSTALL_PATH"
+}
+
+setup_virtualenv() {
+    log_info "Setting up Python virtual environment..."
+
+    cd "$INSTALL_PATH"
+
+    if [[ "$OS_TYPE" == "macos" ]]; then
+        $PYTHON_CMD -m venv venv
+        source venv/bin/activate
+    else
+        sudo -u "$SERVICE_USER" $PYTHON_CMD -m venv venv 2>/dev/null || $PYTHON_CMD -m venv venv
+        source venv/bin/activate
+    fi
+
+    pip install --upgrade pip
+    pip install -r requirements.txt
+
+    log_success "Virtual environment configured"
+}
+
+check_node_version() {
+    # Returns 0 if Node.js 20+ is available, 1 otherwise
+    if ! command -v node &>/dev/null; then
+        return 1
+    fi
+
+    local version
+    version=$(node --version 2>/dev/null | sed 's/^v//')
+    local major
+    major=$(echo "$version" | cut -d'.' -f1)
+
+    if [[ "$major" -ge 20 ]]; then
+        log_success "Found Node.js v$version"
+        return 0
+    else
+        log_warn "Found Node.js v$version (need 20+)"
+        return 1
+    fi
+}
+
+install_nodejs() {
+    log_info "Installing Node.js 22..."
+    case "$PKG_MANAGER" in
+        apt)
+            # Remove old nodejs if present
+            sudo apt-get remove -y nodejs npm 2>/dev/null || true
+            curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
+            sudo apt-get install -y nodejs
+            ;;
+        dnf|yum)
+            sudo $PKG_MANAGER remove -y nodejs npm 2>/dev/null || true
+            curl -fsSL https://rpm.nodesource.com/setup_22.x | sudo bash -
+            sudo $PKG_MANAGER install -y nodejs
+            ;;
+        pacman)
+            sudo pacman -S --noconfirm nodejs npm
+            ;;
+        zypper)
+            sudo zypper install -y nodejs22
+            ;;
+        brew)
+            brew install node@22
+            brew link --overwrite node@22
+            ;;
+        *)
+            log_error "Please install Node.js 20+ manually: https://nodejs.org/"
+            exit 1
+            ;;
+    esac
+    # Refresh PATH
+    hash -r 2>/dev/null || true
+}
+
+build_frontend() {
+    log_info "Building frontend..."
+
+    cd "$INSTALL_PATH/frontend"
+
+    # Check for Node.js 20+
+    if ! check_node_version; then
+        install_nodejs
+        # Verify installation
+        if ! check_node_version; then
+            log_error "Failed to install Node.js 20+. Please install manually."
+            exit 1
+        fi
+    fi
+
+    npm ci
+    npm run build
+
+    log_success "Frontend built"
+}
+
+create_directories() {
+    log_info "Creating data directories..."
+
+    sudo mkdir -p "$DATA_DIR" "$LOG_DIR"
+
+    if [[ "$OS_TYPE" != "macos" ]]; then
+        sudo chown -R "$SERVICE_USER:$SERVICE_USER" "$DATA_DIR" "$LOG_DIR"
+    fi
+
+    log_success "Directories created"
+}
+
+create_env_file() {
+    log_info "Creating environment configuration..."
+
+    local env_file="$INSTALL_PATH/.env"
+
+    # Note: Only include settings recognized by the app's pydantic Settings class
+    # Other settings (PORT, BIND_ADDRESS, DATA_DIR, LOG_DIR, TZ) are set in systemd service
+    cat > /tmp/bambuddy.env << EOF
+# BamBuddy Configuration
+# Generated by install.sh on $(date)
+
+# Debug mode (true = verbose logging)
+DEBUG=$DEBUG_MODE
+
+# Log level (only used when DEBUG=false)
+# Options: DEBUG, INFO, WARNING, ERROR
+LOG_LEVEL=$LOG_LEVEL
+
+# Enable file logging
+LOG_TO_FILE=true
+EOF
+
+    sudo mv /tmp/bambuddy.env "$env_file"
+    if [[ "$OS_TYPE" != "macos" ]]; then
+        sudo chown "$SERVICE_USER:$SERVICE_USER" "$env_file"
+    fi
+    sudo chmod 600 "$env_file"
+
+    log_success "Environment file created at $env_file"
+}
+
+create_systemd_service() {
+    if [[ "$OS_TYPE" == "macos" ]] || [[ "$SKIP_SERVICE" == "true" ]]; then
+        return
+    fi
+
+    log_info "Creating systemd service..."
+
+    cat > /tmp/bambuddy.service << EOF
+[Unit]
+Description=BamBuddy - Bambu Lab Print Management
+Documentation=https://github.com/maziggy/bambuddy
+After=network.target
+
+[Service]
+Type=simple
+User=$SERVICE_USER
+Group=$SERVICE_USER
+WorkingDirectory=$INSTALL_PATH
+
+# App settings from .env file
+EnvironmentFile=$INSTALL_PATH/.env
+
+# Service settings (not in .env to avoid pydantic validation errors)
+Environment="DATA_DIR=$DATA_DIR"
+Environment="LOG_DIR=$LOG_DIR"
+Environment="TZ=$TIMEZONE"
+
+ExecStart=$INSTALL_PATH/venv/bin/uvicorn backend.app.main:app --host $BIND_ADDRESS --port $PORT
+Restart=on-failure
+RestartSec=5
+StandardOutput=journal
+StandardError=journal
+
+# Security hardening
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+ReadWritePaths=$DATA_DIR $LOG_DIR $INSTALL_PATH
+
+[Install]
+WantedBy=multi-user.target
+EOF
+
+    sudo mv /tmp/bambuddy.service /etc/systemd/system/bambuddy.service
+    sudo systemctl daemon-reload
+
+    log_success "Systemd service created"
+
+    if prompt_yes_no "Enable BamBuddy to start on boot?" "y"; then
+        sudo systemctl enable bambuddy
+        log_success "Service enabled"
+    fi
+
+    if prompt_yes_no "Start BamBuddy now?" "y"; then
+        sudo systemctl start bambuddy
+        sleep 2
+        if sudo systemctl is-active --quiet bambuddy; then
+            log_success "BamBuddy is running"
+        else
+            log_warn "Service may have failed to start. Check: sudo journalctl -u bambuddy -f"
+        fi
+    fi
+}
+
+create_launchd_service() {
+    if [[ "$OS_TYPE" != "macos" ]] || [[ "$SKIP_SERVICE" == "true" ]]; then
+        return
+    fi
+
+    log_info "Creating launchd service..."
+
+    local plist_path="$HOME/Library/LaunchAgents/com.bambuddy.app.plist"
+
+    cat > "$plist_path" << EOF
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+    <key>Label</key>
+    <string>com.bambuddy.app</string>
+    <key>ProgramArguments</key>
+    <array>
+        <string>$INSTALL_PATH/venv/bin/uvicorn</string>
+        <string>backend.app.main:app</string>
+        <string>--host</string>
+        <string>$BIND_ADDRESS</string>
+        <string>--port</string>
+        <string>$PORT</string>
+    </array>
+    <key>WorkingDirectory</key>
+    <string>$INSTALL_PATH</string>
+    <key>EnvironmentVariables</key>
+    <dict>
+        <key>DEBUG</key>
+        <string>$DEBUG_MODE</string>
+        <key>LOG_LEVEL</key>
+        <string>$LOG_LEVEL</string>
+        <key>DATA_DIR</key>
+        <string>$DATA_DIR</string>
+        <key>LOG_DIR</key>
+        <string>$LOG_DIR</string>
+        <key>TZ</key>
+        <string>$TIMEZONE</string>
+    </dict>
+    <key>RunAtLoad</key>
+    <true/>
+    <key>KeepAlive</key>
+    <true/>
+    <key>StandardOutPath</key>
+    <string>$LOG_DIR/bambuddy.log</string>
+    <key>StandardErrorPath</key>
+    <string>$LOG_DIR/bambuddy.error.log</string>
+</dict>
+</plist>
+EOF
+
+    log_success "Launchd plist created at $plist_path"
+
+    if prompt_yes_no "Load BamBuddy service now?" "y"; then
+        launchctl load "$plist_path"
+        sleep 2
+        if launchctl list | grep -q "com.bambuddy.app"; then
+            log_success "BamBuddy is running"
+        else
+            log_warn "Service may have failed to start. Check: cat $LOG_DIR/bambuddy.error.log"
+        fi
+    fi
+}
+
+# -----------------------------------------------------------------------------
+# Main Installation Flow
+# -----------------------------------------------------------------------------
+
+parse_args() {
+    while [[ $# -gt 0 ]]; do
+        case "$1" in
+            --path)
+                INSTALL_PATH="$2"
+                shift 2
+                ;;
+            --port)
+                PORT="$2"
+                shift 2
+                ;;
+            --bind)
+                BIND_ADDRESS="$2"
+                shift 2
+                ;;
+            --tz)
+                TIMEZONE="$2"
+                shift 2
+                ;;
+            --data-dir)
+                DATA_DIR="$2"
+                shift 2
+                ;;
+            --log-dir)
+                LOG_DIR="$2"
+                shift 2
+                ;;
+            --debug)
+                DEBUG_MODE="true"
+                shift
+                ;;
+            --log-level)
+                LOG_LEVEL="$2"
+                shift 2
+                ;;
+            --no-service)
+                SKIP_SERVICE="true"
+                shift
+                ;;
+            --set-system-tz)
+                SET_SYSTEM_TZ="true"
+                shift
+                ;;
+            --yes|-y)
+                NON_INTERACTIVE="true"
+                shift
+                ;;
+            --help|-h)
+                show_help
+                ;;
+            *)
+                log_error "Unknown option: $1"
+                show_help
+                ;;
+        esac
+    done
+}
+
+gather_config() {
+    echo ""
+    echo -e "${BOLD}Installation Configuration${NC}"
+    echo -e "${CYAN}─────────────────────────────────────────${NC}"
+    echo ""
+
+    # Installation path
+    [[ -z "$INSTALL_PATH" ]] && prompt "Installation directory" "$DEFAULT_INSTALL_PATH" INSTALL_PATH
+
+    # Port
+    [[ -z "$PORT" ]] && prompt "Port to listen on" "$DEFAULT_PORT" PORT
+
+    # Bind address
+    if [[ -z "$BIND_ADDRESS" ]]; then
+        echo ""
+        echo "Network access:"
+        echo "  0.0.0.0   - Accessible from other devices on your network (recommended)"
+        echo "  127.0.0.1 - Only accessible from this machine"
+        prompt "Bind address" "$DEFAULT_BIND_ADDRESS" BIND_ADDRESS
+    fi
+
+    # Timezone
+    detect_timezone
+    prompt "Timezone" "$TIMEZONE" TIMEZONE
+
+    # Offer to set system timezone if different from current (skip if already set via --set-system-tz)
+    if [[ -z "$SET_SYSTEM_TZ" ]]; then
+        local current_tz
+        current_tz=$(timedatectl show --property=Timezone --value 2>/dev/null) || true
+        if [[ -n "$TIMEZONE" ]] && [[ "$TIMEZONE" != "$current_tz" ]]; then
+            # Default to "n" so unattended installs don't change system TZ unless --set-system-tz is used
+            if prompt_yes_no "Set system timezone to $TIMEZONE?" "n"; then
+                SET_SYSTEM_TZ="true"
+            else
+                SET_SYSTEM_TZ="false"
+            fi
+        else
+            SET_SYSTEM_TZ="false"
+        fi
+    fi
+
+    # Data directory
+    [[ -z "$DATA_DIR" ]] && DATA_DIR="$INSTALL_PATH/data"
+    prompt "Data directory" "$DATA_DIR" DATA_DIR
+
+    # Log directory
+    [[ -z "$LOG_DIR" ]] && LOG_DIR="$INSTALL_PATH/logs"
+    prompt "Log directory" "$LOG_DIR" LOG_DIR
+
+    # Debug mode
+    if [[ -z "$DEBUG_MODE" ]]; then
+        if prompt_yes_no "Enable debug mode?" "n"; then
+            DEBUG_MODE="true"
+        else
+            DEBUG_MODE="false"
+        fi
+    fi
+
+    # Log level
+    if [[ -z "$LOG_LEVEL" ]]; then
+        echo ""
+        echo "Log levels: DEBUG, INFO, WARNING, ERROR"
+        prompt "Log level" "$DEFAULT_LOG_LEVEL" LOG_LEVEL
+    fi
+
+    # Confirm
+    echo ""
+    echo -e "${BOLD}Installation Summary${NC}"
+    echo -e "${CYAN}─────────────────────────────────────────${NC}"
+    echo -e "  Install path:  ${GREEN}$INSTALL_PATH${NC}"
+    echo -e "  Port:          ${GREEN}$PORT${NC}"
+    echo -e "  Bind address:  ${GREEN}$BIND_ADDRESS${NC}"
+    echo -e "  Timezone:      ${GREEN}$TIMEZONE${NC}"
+    echo -e "  Data dir:      ${GREEN}$DATA_DIR${NC}"
+    echo -e "  Log dir:       ${GREEN}$LOG_DIR${NC}"
+    echo -e "  Debug mode:    ${GREEN}$DEBUG_MODE${NC}"
+    echo -e "  Log level:     ${GREEN}$LOG_LEVEL${NC}"
+    echo ""
+
+    if ! prompt_yes_no "Proceed with installation?" "y"; then
+        echo "Installation cancelled."
+        exit 0
+    fi
+}
+
+main() {
+    parse_args "$@"
+    print_banner
+
+    # Check if running via pipe (curl | bash) - interactive mode won't work
+    if [[ ! -t 0 ]] && [[ "$NON_INTERACTIVE" != "true" ]]; then
+        log_error "Interactive mode requires a terminal."
+        log_info "When using 'curl | bash', you must use non-interactive mode:"
+        echo ""
+        echo "    curl -fsSL URL | bash -s -- --yes"
+        echo ""
+        log_info "Or download and run directly:"
+        echo ""
+        echo "    curl -fsSL URL -o install.sh && chmod +x install.sh && ./install.sh"
+        echo ""
+        exit 1
+    fi
+
+    # Check for root (we need sudo for some operations)
+    if [[ "$EUID" -eq 0 ]] && [[ "$OS_TYPE" != "macos" ]]; then
+        log_warn "Running as root. Consider using a regular user with sudo privileges."
+    fi
+
+    # Detect system
+    log_info "Detecting system..."
+    detect_os
+    log_success "Detected: $OS_TYPE (package manager: $PKG_MANAGER)"
+
+    # Check/install Python
+    if ! detect_python; then
+        log_info "Python 3.10+ not found, will install..."
+    fi
+
+    # Gather configuration
+    gather_config
+
+    # Install steps
+    echo ""
+    echo -e "${BOLD}Starting Installation${NC}"
+    echo -e "${CYAN}─────────────────────────────────────────${NC}"
+    echo ""
+
+    install_dependencies
+    detect_python || { log_error "Failed to install Python"; exit 1; }
+
+    # Set system timezone if requested
+    if [[ "$SET_SYSTEM_TZ" == "true" ]]; then
+        log_info "Setting system timezone to $TIMEZONE..."
+        if [[ "$OS_TYPE" == "macos" ]]; then
+            sudo systemsetup -settimezone "$TIMEZONE" 2>/dev/null || true
+        else
+            sudo timedatectl set-timezone "$TIMEZONE" 2>/dev/null || true
+        fi
+        log_success "System timezone set to $TIMEZONE"
+    fi
+
+    if [[ "$OS_TYPE" != "macos" ]]; then
+        create_user
+    else
+        SERVICE_USER="$USER"
+    fi
+
+    download_bambuddy
+    setup_virtualenv
+    build_frontend
+    create_directories
+    create_env_file
+
+    if [[ "$OS_TYPE" == "macos" ]]; then
+        create_launchd_service
+    else
+        create_systemd_service
+    fi
+
+    # Done!
+    echo ""
+    echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}"
+    echo -e "${GREEN}║                                                              ║${NC}"
+    echo -e "${GREEN}║              Installation Complete!                          ║${NC}"
+    echo -e "${GREEN}║                                                              ║${NC}"
+    echo -e "${GREEN}╚══════════════════════════════════════════════════════════════╝${NC}"
+    echo ""
+    # Show appropriate URL based on bind address
+    if [[ "$BIND_ADDRESS" == "0.0.0.0" ]]; then
+        local ip_addr
+        ip_addr=$(hostname -I 2>/dev/null | awk '{print $1}') || ip_addr="<your-ip>"
+        echo -e "  ${BOLD}Access BamBuddy:${NC}  ${CYAN}http://localhost:$PORT${NC}"
+        echo -e "                    ${CYAN}http://$ip_addr:$PORT${NC} (from other devices)"
+    else
+        echo -e "  ${BOLD}Access BamBuddy:${NC}  ${CYAN}http://localhost:$PORT${NC}"
+    fi
+    echo ""
+    if [[ "$OS_TYPE" == "macos" ]]; then
+        echo -e "  ${BOLD}Manage service:${NC}"
+        echo -e "    Start:   launchctl load ~/Library/LaunchAgents/com.bambuddy.app.plist"
+        echo -e "    Stop:    launchctl unload ~/Library/LaunchAgents/com.bambuddy.app.plist"
+        echo -e "    Logs:    tail -f $LOG_DIR/bambuddy.log"
+    else
+        echo -e "  ${BOLD}Manage service:${NC}"
+        echo -e "    Status:  sudo systemctl status bambuddy"
+        echo -e "    Start:   sudo systemctl start bambuddy"
+        echo -e "    Stop:    sudo systemctl stop bambuddy"
+        echo -e "    Logs:    sudo journalctl -u bambuddy -f"
+    fi
+    echo ""
+    echo -e "  ${BOLD}Update BamBuddy:${NC}"
+    echo -e "    cd $INSTALL_PATH && git pull && source venv/bin/activate"
+    echo -e "    pip install -r requirements.txt && cd frontend && npm ci && npm run build"
+    if [[ "$OS_TYPE" != "macos" ]]; then
+        echo -e "    sudo systemctl restart bambuddy"
+    fi
+    echo ""
+    echo -e "  ${BOLD}Documentation:${NC}  ${CYAN}https://wiki.bambuddy.cool${NC}"
+    echo ""
+}
+
+main "$@"

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


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


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


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


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