Browse Source

Merge pull request #185 from maziggy/feature/173

Add MQTT smart plug support for energy monitoring (Issue #173)
MartinNYHC 7 tháng trước cách đây
mục cha
commit
cbd0ab62c2

+ 11 - 53
CHANGELOG.md

@@ -5,21 +5,17 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.1.6-final] - Not released
 
 ### New Features
-- **Home Assistant Script Support** - Control multiple devices together using HA scripts (Issue #176):
-  - Add HA script entities (e.g., `script.turn_on_printer_setup`) as smart plugs
-  - Trigger scripts to control printer + enclosure fan + other devices simultaneously
-  - Scripts show "Run" button instead of On/Off (scripts execute once when triggered)
-  - Script automation: Run scripts automatically when main printer plug turns on/off
-  - Show/hide scripts on printer cards (configurable per script)
-  - Scripts appear in dedicated row on printer cards with quick-run buttons
-- **STL Thumbnail Generation** - Auto-generate preview thumbnails for STL files (Issue #156):
-  - Checkbox option when uploading STL files to generate thumbnails automatically
-  - Batch generate thumbnails for existing STL files via "Generate Thumbnails" button
-  - Individual file thumbnail generation via context menu (three-dot menu)
-  - Works with ZIP extraction (generates thumbnails for all STL files in archive)
-  - 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
+- **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
@@ -85,13 +81,6 @@ All notable changes to Bambuddy will be documented in this file.
   - Link archives to Printables, Thingiverse, or any other URL
   - Globe button opens external link when set, falls back to auto-detected MakerWorld URL
   - Edit via archive edit modal
-- **Tag Management** - Centralized tag management for print archives (Issue #183):
-  - View all tags with usage counts in a dedicated modal
-  - Rename tags across all archives with one action
-  - Delete tags from all archives with confirmation
-  - Search and filter tags by name
-  - Sort by usage count or alphabetically
-  - Access via gear icon next to tag filter dropdown on Archives page
   - Included in backup/restore
 - **External Network Camera Support** - Add external cameras (MJPEG, RTSP, HTTP snapshot) to replace built-in printer cameras (Issue #143):
   - Configure per-printer external camera URL and type in Settings → Camera
@@ -112,33 +101,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
-- **Installation Scripts** - Interactive install scripts for Linux and macOS:
-  - Native install (`install.sh`): Python venv, systemd/launchd service, Node.js 22
-  - Docker install (`docker-install.sh`): Docker Compose setup with health checks
-  - Interactive prompts for: install path, port, bind address, timezone, data/log directories
-  - Unattended mode with `--yes` flag for automation
-  - Auto-detects OS and package manager (apt, dnf, pacman, brew)
-  - Option to set system timezone during installation
-  - Shows IP address for network access when binding to 0.0.0.0
-  - Supports updating existing installations
 
 ### Fixes
 - **Multi-Plate Thumbnail in Queue** - Fixed queue items showing wrong thumbnail for multi-plate files (Issue #166):
@@ -180,10 +142,6 @@ All notable changes to Bambuddy will be documented in this file.
   - Tooltip shows full name on hover
 - **K-Profiles Backup Status** - Fixed GitHub backup settings showing incorrect printer connection count (e.g., "1/2 connected" when both printers are connected); now fetches status from API instead of relying on WebSocket cache
 - **GitHub Backup Timestamps** - Removed volatile timestamps from GitHub backup files so git diffs only show actual data changes
-- **Home Assistant Smart Plug Auto-On** - Fixed print scheduler only checking Tasmota plugs when powering on printers for queued prints (Issue #200):
-  - Home Assistant smart plugs now work with automatic printer power-on for scheduled queue jobs
-  - Previously only Tasmota plugs were checked, causing HA plugs to fail with "Tasmota device not found" error
-  - Both `_power_on_and_wait` and `_power_off_if_needed` now use the correct service based on plug type
 
 ### Maintenance
 - Upgraded vitest from 2.x to 3.x to resolve npm audit security vulnerabilities in dev dependencies

+ 2 - 1
README.md

@@ -81,7 +81,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

+ 15 - 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)

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

@@ -367,6 +367,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,
@@ -1279,12 +1294,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()
@@ -1301,6 +1327,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)
@@ -1331,6 +1372,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),

+ 171 - 1
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
@@ -72,12 +73,68 @@ async def create_smart_plug(
                 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}")
@@ -359,12 +416,74 @@ async def update_smart_plug(
                 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)
 
     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
 
@@ -378,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()
 
@@ -411,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":
@@ -510,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)
 

+ 40 - 92
backend/app/core/database.py

@@ -760,118 +760,66 @@ async def run_migrations(conn):
     except Exception:
         pass
 
-    # 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
+    # Migration: Add MQTT smart plug fields (legacy)
     try:
-        # 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"))
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_topic VARCHAR(200)"))
     except Exception:
         pass
-
-    # Migration: Add show_on_printer_card column to smart_plugs
     try:
-        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN show_on_printer_card BOOLEAN DEFAULT 1"))
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_power_path VARCHAR(100)"))
     except Exception:
         pass
-
-    # Migration: Add sliced_for_model column to print_archives for printer model from 3MF
     try:
-        await conn.execute(text("ALTER TABLE print_archives ADD COLUMN sliced_for_model VARCHAR(50)"))
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_path VARCHAR(100)"))
     except Exception:
         pass
-
-    # Migration: Add target_model column to print_queue for model-based assignment
     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 mqtt_state_path VARCHAR(100)"))
     except Exception:
         pass
-
-    # Migration: Add required_filament_types column to print_queue for filament validation
     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 smart_plugs ADD COLUMN mqtt_power_topic VARCHAR(200)"))
+    except Exception:
+        pass
     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_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
 
 
 async def seed_notification_templates():

+ 34 - 1
backend/app/main.py

@@ -248,9 +248,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
@@ -259,6 +260,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)
 
@@ -2395,6 +2407,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)

+ 33 - 6
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,8 +24,36 @@ 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
 
-    # Link to printer (scripts can coexist with regular plugs for multi-device control)
-    printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="SET NULL"), nullable=True)
+    # 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
+    )
 
     # Automation settings
     enabled: Mapped[bool] = mapped_column(Boolean, default=True)
@@ -52,9 +80,8 @@ class SmartPlug(Base):
     schedule_on_time: Mapped[str | None] = mapped_column(String(5), nullable=True)  # "HH:MM" format
     schedule_off_time: Mapped[str | None] = mapped_column(String(5), nullable=True)  # "HH:MM" format
 
-    # Visibility options
+    # Switchbar visibility
     show_in_switchbar: Mapped[bool] = mapped_column(Boolean, default=False)
-    show_on_printer_card: Mapped[bool] = mapped_column(Boolean, default=True)  # For scripts: show on printer card
 
     # Status tracking
     last_state: Mapped[str | None] = mapped_column(String(10), nullable=True)  # "ON"/"OFF"

+ 52 - 2
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}$")
@@ -20,6 +20,30 @@ class SmartPlugBase(BaseModel):
     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
@@ -45,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
 
 
@@ -54,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

+ 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()

+ 34 - 0
backend/tests/conftest.py

@@ -216,6 +216,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."""
@@ -296,6 +314,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

+ 191 - 136
backend/tests/integration/test_smart_plugs_api.py

@@ -574,56 +574,62 @@ 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_homeassistant_script_plug(self, async_client: AsyncClient):
-        """Verify Home Assistant script entity can be created as a plug.
-
-        Scripts allow users to trigger HA automations that control multiple devices
-        (e.g., turn on printer + fan together). Scripts can only be triggered (turn_on),
-        not turned off.
-        """
+    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": "Turn On Printer Setup",
-            "plug_type": "homeassistant",
-            "ha_entity_id": "script.turn_on_printer_and_fan",
+            "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,
-            "auto_on": True,
-            "auto_off": False,  # Scripts don't support auto_off
         }
 
         response = await async_client.post("/api/v1/smart-plugs/", json=data)
 
         assert response.status_code == 200
         result = response.json()
-        assert result["name"] == "Turn On Printer Setup"
-        assert result["plug_type"] == "homeassistant"
-        assert result["ha_entity_id"] == "script.turn_on_printer_and_fan"
+        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_control_homeassistant_script(
-        self, async_client: AsyncClient, smart_plug_factory, mock_homeassistant_service, db_session
-    ):
-        """Verify HA script entity can be triggered via control endpoint."""
-        plug = await smart_plug_factory(plug_type="homeassistant", ha_entity_id="script.turn_on_printer")
+    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,
+        }
 
-        # Scripts use "on" action to trigger
-        response = await async_client.post(f"/api/v1/smart-plugs/{plug.id}/control", json={"action": "on"})
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
 
-        assert response.status_code == 200
-        result = response.json()
-        assert result["success"] is True
-        assert result["action"] == "on"
+        assert response.status_code == 422  # Validation error
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_invalid_ha_entity_domain(self, async_client: AsyncClient):
-        """Verify invalid HA entity domains are rejected."""
+    async def test_create_mqtt_plug_missing_topic(self, async_client: AsyncClient):
+        """Verify creating MQTT plug without any topic fails."""
         data = {
-            "name": "Invalid Entity",
-            "plug_type": "homeassistant",
-            "ha_entity_id": "sensor.some_sensor",  # sensor domain not allowed
+            "name": "MQTT Plug",
+            "plug_type": "mqtt",
+            # No topic configured at all
             "enabled": True,
         }
 
@@ -633,140 +639,189 @@ class TestSmartPlugsAPI:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_script_can_coexist_with_regular_plug(
-        self, async_client: AsyncClient, smart_plug_factory, printer_factory, db_session
-    ):
-        """Verify HA scripts can be assigned to printers that already have a regular plug.
-
-        Scripts are for multi-device control (e.g., turn on printer + fan together),
-        so they should coexist with the main power plug.
-        """
-        # Create a printer
-        printer = await printer_factory(name="Test Printer")
-
-        # Create a regular Tasmota plug assigned to the printer
-        main_plug = await smart_plug_factory(
-            name="Main Power Plug",
-            plug_type="tasmota",
-            ip_address="192.168.1.100",
-            printer_id=printer.id,
-        )
-        assert main_plug.printer_id == printer.id
-
-        # Now try to create a script also assigned to the same printer
-        script_data = {
-            "name": "Turn On Everything",
-            "plug_type": "homeassistant",
-            "ha_entity_id": "script.turn_on_printer_setup",
-            "printer_id": printer.id,
+    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=script_data)
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
 
-        # Should succeed - scripts can coexist with regular plugs
         assert response.status_code == 200
         result = response.json()
-        assert result["printer_id"] == printer.id
-        assert result["ha_entity_id"] == "script.turn_on_printer_setup"
+        assert result["mqtt_multiplier"] == 0.001
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_regular_plug_blocked_when_another_exists(
-        self, async_client: AsyncClient, smart_plug_factory, printer_factory, db_session
-    ):
-        """Verify regular plugs cannot be assigned if printer already has one."""
-        # Create a printer
-        printer = await printer_factory(name="Test Printer")
-
-        # Create a regular plug assigned to the printer
-        await smart_plug_factory(
-            name="Main Power Plug",
-            plug_type="tasmota",
-            ip_address="192.168.1.100",
-            printer_id=printer.id,
+    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",
         )
 
-        # Try to create another regular plug for the same printer
-        another_plug = {
-            "name": "Second Plug",
-            "plug_type": "homeassistant",
-            "ha_entity_id": "switch.another_plug",
-            "printer_id": printer.id,
-            "enabled": True,
-        }
-
-        response = await async_client.post("/api/v1/smart-plugs/", json=another_plug)
+        response = await async_client.post(f"/api/v1/smart-plugs/{plug.id}/control", json={"action": "on"})
 
-        # Should fail - only one regular plug per printer
         assert response.status_code == 400
-        assert "already has a smart plug" in response.json()["detail"]
+        assert "monitor-only" in response.json()["detail"].lower()
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_get_scripts_by_printer_filters_by_show_on_printer_card(
-        self, async_client: AsyncClient, smart_plug_factory, printer_factory, db_session
-    ):
-        """Verify scripts endpoint only returns scripts with show_on_printer_card=True."""
-        printer = await printer_factory(name="Test Printer")
-
-        # Create a script with show_on_printer_card=True (default)
-        visible_script = await smart_plug_factory(
-            name="Visible Script",
-            plug_type="homeassistant",
-            ha_entity_id="script.visible_script",
-            printer_id=printer.id,
-            show_on_printer_card=True,
+    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",
         )
 
-        # Create a script with show_on_printer_card=False
-        await smart_plug_factory(
-            name="Hidden Script",
-            plug_type="homeassistant",
-            ha_entity_id="script.hidden_script",
-            printer_id=printer.id,
-            show_on_printer_card=False,
+        response = await async_client.patch(
+            f"/api/v1/smart-plugs/{plug.id}",
+            json={
+                "mqtt_topic": "new/topic",
+                "mqtt_power_path": "new_power",
+            },
         )
 
-        response = await async_client.get(f"/api/v1/smart-plugs/by-printer/{printer.id}/scripts")
-
         assert response.status_code == 200
-        scripts = response.json()
-        # Should only return the visible script
-        assert len(scripts) == 1
-        assert scripts[0]["id"] == visible_script.id
-        assert scripts[0]["name"] == "Visible Script"
+        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_script_auto_on_auto_off_fields(
-        self, async_client: AsyncClient, smart_plug_factory, printer_factory, db_session
-    ):
-        """Verify scripts can have auto_on and auto_off set for automation triggers."""
-        printer = await printer_factory(name="Test Printer")
+    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,
+        }
 
-        # Create a script with custom auto_on/auto_off settings
-        script_data = {
-            "name": "Fan Control Script",
-            "plug_type": "homeassistant",
-            "ha_entity_id": "script.fan_control",
-            "printer_id": printer.id,
-            "auto_on": True,
-            "auto_off": False,
-            "show_on_printer_card": 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=script_data)
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
 
         assert response.status_code == 200
         result = response.json()
-        assert result["auto_on"] is True
-        assert result["auto_off"] is False
-        assert result["show_on_printer_card"] is True
+        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,
+        }
 
-        # Update the script's auto_off setting
-        update_response = await async_client.patch(f"/api/v1/smart-plugs/{result['id']}", json={"auto_off": True})
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
 
-        assert update_response.status_code == 200
-        updated = update_response.json()
-        assert updated["auto_off"] is True
+        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

+ 88 - 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,74 @@ 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 user = userEvent.setup();
+      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();
+    });
+  });
 });

+ 50 - 3
frontend/src/api/client.ts

@@ -846,13 +846,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", "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;
@@ -884,13 +900,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;
@@ -915,13 +947,28 @@ export interface SmartPlugCreate {
 
 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;

+ 290 - 177
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, Play, Eye } 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 || '');
@@ -51,10 +67,6 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
   const [testResult, setTestResult] = useState<{ success: boolean; state?: string | null; device_name?: string | null } | null>(null);
   const [error, setError] = useState<string | null>(null);
 
-  // Automation settings
-  const [autoOn, setAutoOn] = useState(plug?.auto_on ?? true);
-  const [autoOff, setAutoOff] = useState(plug?.auto_off ?? true);
-
   // Power alert settings
   const [powerAlertEnabled, setPowerAlertEnabled] = useState(plug?.power_alert_enabled || false);
   const [powerAlertHigh, setPowerAlertHigh] = useState<string>(plug?.power_alert_high?.toString() || '');
@@ -65,9 +77,8 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
   const [scheduleOnTime, setScheduleOnTime] = useState<string>(plug?.schedule_on_time || '');
   const [scheduleOffTime, setScheduleOffTime] = useState<string>(plug?.schedule_off_time || '');
 
-  // Visibility options
+  // Switchbar visibility
   const [showInSwitchbar, setShowInSwitchbar] = useState(plug?.show_in_switchbar || false);
-  const [showOnPrinterCard, setShowOnPrinterCard] = useState(plug?.show_on_printer_card ?? true);
 
   // Discovery state
   const [isScanning, setIsScanning] = useState(false);
@@ -240,10 +251,6 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
     mutationFn: (data: SmartPlugCreate) => api.createSmartPlug(data),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
-      // Also invalidate script plugs queries for printer cards
-      queryClient.invalidateQueries({ predicate: (query) =>
-        Array.isArray(query.queryKey) && query.queryKey[0] === 'scriptPlugsByPrinter'
-      });
       onClose();
     },
     onError: (err: Error) => {
@@ -256,10 +263,6 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
     mutationFn: (data: SmartPlugUpdate) => api.updateSmartPlug(plug!.id, data),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
-      // Also invalidate script plugs queries for printer cards
-      queryClient.invalidateQueries({ predicate: (query) =>
-        Array.isArray(query.queryKey) && query.queryKey[0] === 'scriptPlugsByPrinter'
-      });
       onClose();
     },
     onError: (err: Error) => {
@@ -267,17 +270,11 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
     },
   });
 
-  // Check if selected entity is a script (scripts allow multiple plugs per printer)
-  const isScript = plugType === 'homeassistant' && haEntityId?.startsWith('script.');
-
   // Filter out printers that already have a plug assigned (except current plug's printer)
-  // Scripts can link to any printer (they're for multi-device control)
-  const availablePrinters = isScript
-    ? printers
-    : printers?.filter(p => {
-        const hasPlug = existingPlugs?.some(ep => ep.printer_id === p.id && ep.id !== plug?.id);
-        return !hasPlug;
-      });
+  const availablePrinters = printers?.filter(p => {
+    const hasPlug = existingPlugs?.some(ep => ep.printer_id === p.id && ep.id !== plug?.id);
+    return !hasPlug;
+  });
 
   const handleSubmit = (e: React.FormEvent) => {
     e.preventDefault();
@@ -298,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,
@@ -307,12 +316,21 @@ 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,
-      // Automation
-      auto_on: autoOn,
-      auto_off: autoOff,
       // Power alerts
       power_alert_enabled: powerAlertEnabled,
       power_alert_high: powerAlertHigh ? parseFloat(powerAlertHigh) : null,
@@ -321,9 +339,8 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
       schedule_enabled: scheduleEnabled,
       schedule_on_time: scheduleOnTime || null,
       schedule_off_time: scheduleOffTime || null,
-      // Visibility options
+      // Switchbar
       show_in_switchbar: showInSwitchbar,
-      show_on_printer_card: showOnPrinterCard,
     };
 
     if (isEditing) {
@@ -375,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'
@@ -391,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>
           )}
@@ -605,15 +638,15 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
                         <p className="text-xs text-bambu-gray mt-1">
                           {debouncedSearch
                             ? `Searching all entities (${availableEntities.length} found)`
-                            : `Showing switch, light, input_boolean, script (${availableEntities.length} available)`}
+                            : `Showing switch, light, input_boolean (${availableEntities.length} available)`}
                         </p>
                       </div>
                     );
                   })()}
 
 
-                  {/* Energy Monitoring Section (Optional) - hidden for scripts */}
-                  {haEntityId && !isScript && haSensorEntities && haSensorEntities.length > 0 && (
+                  {/* Energy Monitoring Section (Optional) */}
+                  {haEntityId && haSensorEntities && haSensorEntities.length > 0 && (
                     <div className="border-t border-bambu-dark-tertiary pt-4 mt-4 space-y-3">
                       <div>
                         <p className="text-white font-medium mb-1">Energy Monitoring (Optional)</p>
@@ -880,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>
@@ -982,128 +1164,82 @@ 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">
-              {isScript
-                ? 'Link to a printer to enable auto-run triggers'
-                : 'Linking enables automatic on/off when prints start/complete'}
-            </p>
-          </div>
-
-          {/* Script Options - only show for HA scripts */}
-          {isScript && (
-            <div className="border-t border-bambu-dark-tertiary pt-4 space-y-4">
-              <div className="flex items-center gap-2 mb-2">
-                <Play className="w-4 h-4 text-bambu-green" />
-                <span className="text-white font-medium">Script Automation</span>
-              </div>
-
-              {/* Auto-run when printer turns on */}
-              <div className="flex items-center justify-between">
-                <div>
-                  <span className="text-white">Run when printer turns on</span>
-                  <p className="text-xs text-bambu-gray">Execute script when main plug is switched on</p>
-                </div>
-                <label className="relative inline-flex items-center cursor-pointer">
-                  <input
-                    type="checkbox"
-                    checked={autoOn}
-                    onChange={(e) => setAutoOn(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>
-
-              {/* Auto-run when printer turns off */}
-              <div className="flex items-center justify-between">
-                <div>
-                  <span className="text-white">Run when printer turns off</span>
-                  <p className="text-xs text-bambu-gray">Execute script when main plug is switched off</p>
-                </div>
-                <label className="relative inline-flex items-center cursor-pointer">
-                  <input
-                    type="checkbox"
-                    checked={autoOff}
-                    onChange={(e) => setAutoOff(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>
+          {/* 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 - hidden for scripts (no power monitoring) */}
-          {!isScript && (
-            <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">
-                  <Bell className="w-4 h-4 text-bambu-green" />
-                  <span className="text-white font-medium">Power Alerts</span>
-                </div>
-                <label className="relative inline-flex items-center cursor-pointer">
-                  <input
-                    type="checkbox"
-                    checked={powerAlertEnabled}
-                    onChange={(e) => setPowerAlertEnabled(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>
+          {/* Power Alerts */}
+          <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">
+                <Bell className="w-4 h-4 text-bambu-green" />
+                <span className="text-white font-medium">Power Alerts</span>
               </div>
-              {powerAlertEnabled && (
-                <div className="space-y-3">
-                  <div className="grid grid-cols-2 gap-3">
-                    <div>
-                      <label className="block text-sm text-bambu-gray mb-1">Alert if above (W)</label>
-                      <input
-                        type="number"
-                        value={powerAlertHigh}
-                        onChange={(e) => setPowerAlertHigh(e.target.value)}
-                        placeholder="e.g. 200"
-                        min="0"
-                        max="5000"
-                        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">Alert if below (W)</label>
-                      <input
-                        type="number"
-                        value={powerAlertLow}
-                        onChange={(e) => setPowerAlertLow(e.target.value)}
-                        placeholder="e.g. 10"
-                        min="0"
-                        max="5000"
-                        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>
+              <label className="relative inline-flex items-center cursor-pointer">
+                <input
+                  type="checkbox"
+                  checked={powerAlertEnabled}
+                  onChange={(e) => setPowerAlertEnabled(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>
+            {powerAlertEnabled && (
+              <div className="space-y-3">
+                <div className="grid grid-cols-2 gap-3">
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">Alert if above (W)</label>
+                    <input
+                      type="number"
+                      value={powerAlertHigh}
+                      onChange={(e) => setPowerAlertHigh(e.target.value)}
+                      placeholder="e.g. 200"
+                      min="0"
+                      max="5000"
+                      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">Alert if below (W)</label>
+                    <input
+                      type="number"
+                      value={powerAlertLow}
+                      onChange={(e) => setPowerAlertLow(e.target.value)}
+                      placeholder="e.g. 10"
+                      min="0"
+                      max="5000"
+                      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>
-                  <p className="text-xs text-bambu-gray">
-                    Get notified when power consumption crosses these thresholds. Leave empty to disable that direction.
-                  </p>
                 </div>
-              )}
-            </div>
-          )}
+                <p className="text-xs text-bambu-gray">
+                  Get notified when power consumption crosses these thresholds. Leave empty to disable that direction.
+                </p>
+              </div>
+            )}
+          </div>
 
-          {/* Schedule - hidden for scripts */}
-          {!isScript && (
+          {/* 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">
@@ -1150,31 +1286,8 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
             </div>
           )}
 
-          {/* Visibility Options */}
-          <div className="border-t border-bambu-dark-tertiary pt-4 space-y-4">
-            {/* Show on printer card - only for scripts */}
-            {isScript && (
-              <div className="flex items-center justify-between">
-                <div className="flex items-center gap-2">
-                  <Eye className="w-4 h-4 text-bambu-green" />
-                  <div>
-                    <span className="text-white font-medium">Show on Printer Card</span>
-                    <p className="text-xs text-bambu-gray">Display script button on printer card</p>
-                  </div>
-                </div>
-                <label className="relative inline-flex items-center cursor-pointer">
-                  <input
-                    type="checkbox"
-                    checked={showOnPrinterCard}
-                    onChange={(e) => setShowOnPrinterCard(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>
-            )}
-
-            {/* Show in Switchbar */}
+          {/* Switchbar Visibility */}
+          <div className="border-t border-bambu-dark-tertiary pt-4">
             <div className="flex items-center justify-between">
               <div className="flex items-center gap-2">
                 <LayoutGrid className="w-4 h-4 text-bambu-green" />

+ 122 - 150
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, Play, Eye } 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';
@@ -55,24 +55,12 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
 
       return { previousStatus };
     },
-    onSuccess: (_data, action) => {
-      // Show toast for script triggers
-      const isScriptPlug = plug.plug_type === 'homeassistant' && plug.ha_entity_id?.startsWith('script.');
-      if (isScriptPlug && action === 'on') {
-        showToast(`Script "${plug.name}" triggered`, 'success');
-      }
-    },
     onError: (_err, action, context) => {
       // Rollback on error
       if (context?.previousStatus) {
         queryClient.setQueryData(['smart-plug-status', plug.id], context.previousStatus);
       }
-      const isScriptPlug = plug.plug_type === 'homeassistant' && plug.ha_entity_id?.startsWith('script.');
-      if (isScriptPlug) {
-        showToast(`Failed to trigger script "${plug.name}"`, 'error');
-      } else {
-        showToast(`Failed to turn ${action} "${plug.name}"`, 'error');
-      }
+      showToast(`Failed to turn ${action} "${plug.name}"`, 'error');
     },
     onSettled: () => {
       // Refetch after a short delay to get actual state
@@ -92,10 +80,6 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
       if (plug.printer_id) {
         queryClient.invalidateQueries({ queryKey: ['smartPlugByPrinter', plug.printer_id] });
       }
-      // Invalidate script plugs queries for printer cards
-      queryClient.invalidateQueries({ predicate: (query) =>
-        Array.isArray(query.queryKey) && query.queryKey[0] === 'scriptPlugsByPrinter'
-      });
     },
   });
 
@@ -104,20 +88,15 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
     mutationFn: () => api.deleteSmartPlug(plug.id),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
-      // Also invalidate script plugs queries for printer cards
-      queryClient.invalidateQueries({ predicate: (query) =>
-        Array.isArray(query.queryKey) && query.queryKey[0] === 'scriptPlugsByPrinter'
-      });
     },
   });
 
   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;
 
-  // Check if this is a HA script entity (scripts can only be triggered, not toggled)
-  const isScript = plug.plug_type === 'homeassistant' && plug.ha_entity_id?.startsWith('script.');
-
   // Generate admin URL with auto-login credentials (Tasmota only)
   const getAdminUrl = () => {
     if (plug.plug_type !== 'tasmota' || !plug.ip_address) return null;
@@ -138,9 +117,13 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
           {/* Header Row */}
           <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 || isScript) ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20'}`}>
-                {isScript ? (
-                  <Play className={`w-5 h-5 ${isReachable ? 'text-bambu-green' : 'text-red-400'}`} />
+              <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'}`} />
                 ) : (
@@ -148,9 +131,12 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
                 )}
               </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" title={plug.plug_type === 'homeassistant' ? plug.ha_entity_id ?? undefined : plug.ip_address ?? undefined}>
-                  {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>
@@ -159,23 +145,23 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
             <div className="flex flex-col items-end gap-1 flex-shrink-0">
               {statusLoading ? (
                 <Loader2 className="w-4 h-4 text-bambu-gray animate-spin" />
-              ) : isScript ? (
-                /* Script entities: show badge and Ready status stacked */
-                <div className="flex flex-col items-end gap-1">
-                  <span className="flex items-center gap-1 px-2 py-0.5 bg-blue-500/20 text-blue-400 text-xs rounded-full">
-                    <Play className="w-3 h-3" />
-                    Script
-                  </span>
-                  <span className={`text-sm ${isReachable ? 'text-status-ok' : 'text-status-error'}`}>
-                    {isReachable ? 'Ready' : 'Offline'}
+              ) : 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" />
-                  <span className={isOn ? 'text-status-ok' : 'text-bambu-gray'}>
-                    {status?.state || 'Unknown'}
-                  </span>
+                  <span className={isOn ? 'text-status-ok' : 'text-bambu-gray'}>{status?.state || 'Unknown'}</span>
                 </div>
               ) : (
                 <div className="flex items-center gap-1 text-sm text-status-error">
@@ -208,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" />
@@ -229,46 +221,49 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
             </div>
           )}
 
-          {/* Quick Controls */}
-          <div className="flex gap-2 mb-3">
-            {isScript ? (
-              /* Script entities: single "Run" button */
+          {/* Quick Controls - hidden for MQTT plugs (monitor-only) */}
+          {plug.plug_type !== 'mqtt' && (
+            <div className="flex gap-2 mb-3">
               <Button
                 size="sm"
-                variant="primary"
+                variant={isOn ? 'primary' : 'secondary'}
                 disabled={!isReachable || isPending}
                 onClick={() => setShowPowerOnConfirm(true)}
                 className="flex-1"
               >
-                {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
-                Run Script
+                {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
+                On
               </Button>
-            ) : (
-              /* Regular entities: On/Off buttons */
-              <>
-                <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>
+              <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
@@ -277,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>
@@ -285,28 +280,6 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
           {/* Expanded Settings */}
           {isExpanded && (
             <div className="pt-3 border-t border-bambu-dark-tertiary space-y-4">
-              {/* Show on Printer Card Toggle - only for scripts */}
-              {isScript && (
-                <div className="flex items-center justify-between">
-                  <div className="flex items-center gap-2">
-                    <Eye className="w-4 h-4 text-bambu-green" />
-                    <div>
-                      <p className="text-sm text-white">Show on Printer Card</p>
-                      <p className="text-xs text-bambu-gray">Display script button on printer card</p>
-                    </div>
-                  </div>
-                  <label className="relative inline-flex items-center cursor-pointer">
-                    <input
-                      type="checkbox"
-                      checked={plug.show_on_printer_card}
-                      onChange={(e) => updateMutation.mutate({ show_on_printer_card: 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>
-              )}
-
               {/* Show in Switchbar Toggle */}
               <div className="flex items-center justify-between">
                 <div className="flex items-center gap-2">
@@ -327,49 +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 / Run when printer turns on */}
-              <div className="flex items-center justify-between">
-                <div>
-                  <p className="text-sm text-white">{isScript ? 'Run when printer turns on' : 'Auto On'}</p>
-                  <p className="text-xs text-bambu-gray">
-                    {isScript ? 'Execute script when main plug is switched on' : '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 / Run when printer turns off */}
-              <div className="flex items-center justify-between">
-                <div>
-                  <p className="text-sm text-white">{isScript ? 'Run when printer turns off' : 'Auto Off'}</p>
-                  <p className="text-xs text-bambu-gray">
-                    {isScript ? 'Execute script when main plug is switched off' : '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
@@ -382,8 +354,8 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
                 </label>
               </div>
 
-              {/* Delay Mode - hidden for script entities */}
-              {plug.auto_off && !isScript && (
+              {/* Delay Mode */}
+              {plug.auto_off && (
                 <div className="space-y-3 pl-4 border-l-2 border-bambu-dark-tertiary">
                   <div>
                     <p className="text-sm text-white mb-2">Turn Off Delay Mode</p>
@@ -441,6 +413,8 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
                   )}
                 </div>
               )}
+                </>
+              )}
 
               {/* Action Buttons */}
               <div className="flex gap-2 pt-2">
@@ -482,14 +456,12 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
         />
       )}
 
-      {/* Power On / Run Script Confirmation */}
+      {/* Power On Confirmation */}
       {showPowerOnConfirm && (
         <ConfirmModal
-          title={isScript ? "Run Script" : "Turn On Smart Plug"}
-          message={isScript
-            ? `Are you sure you want to run the script "${plug.name}"?`
-            : `Are you sure you want to turn on "${plug.name}"?`}
-          confirmText={isScript ? "Run" : "Turn On"}
+          title="Turn On Smart Plug"
+          message={`Are you sure you want to turn on "${plug.name}"?`}
+          confirmText="Turn On"
           variant="default"
           onConfirm={() => {
             controlMutation.mutate('on');

+ 57 - 64
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, Play } 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,11 +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;
-
-  // Check if this is a HA script entity
-  const isScript = plug.plug_type === 'homeassistant' && plug.ha_entity_id?.startsWith('script.');
+  const isMqtt = plug.plug_type === 'mqtt';
 
   const handleConfirm = () => {
     if (confirmAction) {
@@ -46,33 +46,42 @@ 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 || isScript) ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20'}`}>
-            {isScript ? (
-              <Play className={`w-4 h-4 ${isReachable ? 'text-bambu-green' : '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>
-            <div className="flex items-center gap-2">
-              <p className="text-sm text-white font-medium">{plug.name}</p>
-              {isScript && (
-                <span className="px-1.5 py-0.5 bg-blue-500/20 text-blue-400 text-[10px] rounded-full">Script</span>
-              )}
-            </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" />
-              ) : isScript ? (
-                <span className={isReachable ? 'text-status-ok' : 'text-status-error'}>
-                  {isReachable ? 'Ready' : 'Offline'}
-                </span>
+              ) : 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" />
-                  <span className={isOn ? 'text-status-ok' : 'text-bambu-gray'}>
-                    {status?.state || 'Unknown'}
-                  </span>
+                  <span className={isOn ? 'text-status-ok' : 'text-bambu-gray'}>{status?.state || 'Unknown'}</span>
                   {status?.energy?.power !== null && status?.energy?.power !== undefined && (
                     <>
                       <span className="text-bambu-gray mx-1">|</span>
@@ -91,58 +100,42 @@ function SwitchItem({ plug }: { plug: SmartPlug }) {
           </div>
         </div>
 
-        <div className="flex gap-1">
-          {isScript ? (
-            /* Script: single Run button */
+        {/* 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 bg-bambu-green text-white disabled:opacity-50 disabled:cursor-not-allowed"
-              title="Run Script"
+              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" /> : <Play className="w-4 h-4" />}
+              {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
             </button>
-          ) : (
-            /* Regular: On/Off buttons */
-            <>
-              <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>
+            <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 && (
         <ConfirmModal
-          title={isScript && confirmAction === 'on'
-            ? 'Run Script'
-            : `Turn ${confirmAction === 'on' ? 'On' : 'Off'} Smart Plug`}
-          message={isScript && confirmAction === 'on'
-            ? `Are you sure you want to run the script "${plug.name}"?`
-            : `Are you sure you want to turn ${confirmAction === 'on' ? 'on' : 'off'} "${plug.name}"?`}
-          confirmText={isScript && confirmAction === 'on' ? 'Run' : (confirmAction === 'on' ? 'Turn On' : 'Turn Off')}
+          title={`Turn ${confirmAction === 'on' ? 'On' : 'Off'} Smart Plug`}
+          message={`Are you sure you want to turn ${confirmAction === 'on' ? 'on' : 'off'} "${plug.name}"?`}
+          confirmText={confirmAction === 'on' ? 'Turn On' : 'Turn Off'}
           variant={confirmAction === 'off' ? 'warning' : 'default'}
           onConfirm={handleConfirm}
           onCancel={() => setConfirmAction(null)}

+ 10 - 6
frontend/src/pages/SettingsPage.tsx

@@ -138,13 +138,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;
         }
       }
 

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
static/assets/index-BJQC4Kk-.js


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
static/assets/index-BrclLX7E.css


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
static/assets/index-C1mIxrzF.css


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
static/assets/index-Dm1kl7bT.js


+ 2 - 2
static/index.html

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

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác