Explorar o código

Merge pull request #185 from maziggy/feature/173

Add MQTT smart plug support for energy monitoring (Issue #173)
MartinNYHC hai 7 meses
pai
achega
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
 ## [0.1.6-final] - Not released
 
 
 ### New Features
 ### 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:
 - **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
   - Prevents Bambuddy from checking Bambu Lab servers for firmware updates
   - Useful for users who prefer to manage firmware manually or have network restrictions
   - 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
   - Link archives to Printables, Thingiverse, or any other URL
   - Globe button opens external link when set, falls back to auto-detected MakerWorld URL
   - Globe button opens external link when set, falls back to auto-detected MakerWorld URL
   - Edit via archive edit modal
   - 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
   - Included in backup/restore
 - **External Network Camera Support** - Add external cameras (MJPEG, RTSP, HTTP snapshot) to replace built-in printer cameras (Issue #143):
 - **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
   - 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 edit: printer assignment, print options, queue options
   - Bulk cancel selected items
   - Bulk cancel selected items
   - Tri-state toggles: unchanged / on / off for each setting
   - 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
 ### Fixes
 - **Multi-Plate Thumbnail in Queue** - Fixed queue items showing wrong thumbnail for multi-plate files (Issue #166):
 - **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
   - 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
 - **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
 - **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
 ### Maintenance
 - Upgraded vitest from 2.x to 3.x to resolve npm audit security vulnerabilities in dev dependencies
 - 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)
 - Per-printer AMS mapping (individual slot configuration for print farms)
 - Scheduled prints (date/time)
 - Scheduled prints (date/time)
 - Queue Only mode (stage without auto-start)
 - 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)
 - Energy consumption tracking (per-print kWh and cost)
 - HA energy sensor support (for plugs with separate power/energy sensors)
 - HA energy sensor support (for plugs with separate power/energy sensors)
 - Auto power-on before print
 - 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":
     if energy_tracking_mode == "total":
         # Total mode: sum up 'total' counter from all smart plugs (lifetime consumption)
         # Total mode: sum up 'total' counter from all smart plugs (lifetime consumption)
         from backend.app.models.smart_plug import SmartPlug
         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
         from backend.app.services.tasmota import tasmota_service
 
 
         plugs_result = await db.execute(select(SmartPlug))
         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
         total_energy_kwh = 0.0
         for plug in plugs:
         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_kwh = round(total_energy_kwh, 3)
         total_energy_cost = round(total_energy_kwh * energy_cost_per_kwh, 2)
         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_power_entity": plug.ha_power_entity,
                     "ha_energy_today_entity": plug.ha_energy_today_entity,
                     "ha_energy_today_entity": plug.ha_energy_today_entity,
                     "ha_energy_total_entity": plug.ha_energy_total_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,
                     "printer_serial": printer_id_to_serial.get(plug.printer_id) if plug.printer_id else None,
                     "enabled": plug.enabled,
                     "enabled": plug.enabled,
                     "auto_on": plug.auto_on,
                     "auto_on": plug.auto_on,
@@ -1279,12 +1294,23 @@ async def import_backup(
             # Determine plug type (default to tasmota for backwards compatibility)
             # Determine plug type (default to tasmota for backwards compatibility)
             plug_type = plug_data.get("plug_type", "tasmota")
             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
             existing = None
+            plug_identifier = None
             if plug_type == "homeassistant" and plug_data.get("ha_entity_id"):
             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"]))
                 result = await db.execute(select(SmartPlug).where(SmartPlug.ha_entity_id == plug_data["ha_entity_id"]))
                 existing = result.scalar_one_or_none()
                 existing = result.scalar_one_or_none()
                 plug_identifier = plug_data["ha_entity_id"]
                 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"):
             elif plug_data.get("ip_address"):
                 result = await db.execute(select(SmartPlug).where(SmartPlug.ip_address == plug_data["ip_address"]))
                 result = await db.execute(select(SmartPlug).where(SmartPlug.ip_address == plug_data["ip_address"]))
                 existing = result.scalar_one_or_none()
                 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_power_entity = plug_data.get("ha_power_entity")
                     existing.ha_energy_today_entity = plug_data.get("ha_energy_today_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")
                     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.printer_id = printer_id
                     existing.enabled = plug_data.get("enabled", True)
                     existing.enabled = plug_data.get("enabled", True)
                     existing.auto_on = plug_data.get("auto_on", 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_power_entity=plug_data.get("ha_power_entity"),
                     ha_energy_today_entity=plug_data.get("ha_energy_today_entity"),
                     ha_energy_today_entity=plug_data.get("ha_energy_today_entity"),
                     ha_energy_total_entity=plug_data.get("ha_energy_total_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,
                     printer_id=printer_id,
                     enabled=plug_data.get("enabled", True),
                     enabled=plug_data.get("enabled", True),
                     auto_on=plug_data.get("auto_on", 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.discovery import tasmota_scanner
 from backend.app.services.homeassistant import homeassistant_service
 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.notification_service import notification_service
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.tasmota import tasmota_service
 from backend.app.services.tasmota import tasmota_service
@@ -72,12 +73,68 @@ async def create_smart_plug(
                 if not existing_is_script:
                 if not existing_is_script:
                     raise HTTPException(400, "This printer already has a smart plug assigned")
                     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())
     plug = SmartPlug(**data.model_dump())
     db.add(plug)
     db.add(plug)
     await db.commit()
     await db.commit()
     await db.refresh(plug)
     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})")
         logger.info(f"Created Home Assistant plug '{plug.name}' ({plug.ha_entity_id})")
     else:
     else:
         logger.info(f"Created Tasmota plug '{plug.name}' at {plug.ip_address}")
         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:
                 if not existing_is_script:
                     raise HTTPException(400, "This printer already has a smart plug assigned")
                     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():
     for field, value in update_data.items():
         setattr(plug, field, value)
         setattr(plug, field, value)
 
 
     await db.commit()
     await db.commit()
     await db.refresh(plug)
     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}'")
     logger.info(f"Updated smart plug '{plug.name}'")
     return plug
     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")
         raise HTTPException(404, "Smart plug not found")
 
 
     plug_name = plug.name
     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.delete(plug)
     await db.commit()
     await db.commit()
 
 
@@ -411,6 +536,13 @@ async def control_smart_plug(
     if not plug:
     if not plug:
         raise HTTPException(404, "Smart plug not found")
         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)
     service = await _get_service_for_plug(plug, db)
 
 
     if control.action == "on":
     if control.action == "on":
@@ -510,6 +642,44 @@ async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)):
     if not plug:
     if not plug:
         raise HTTPException(404, "Smart plug not found")
         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)
     service = await _get_service_for_plug(plug, db)
     status = await service.get_status(plug)
     status = await service.get_status(plug)
 
 

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

@@ -760,118 +760,66 @@ async def run_migrations(conn):
     except Exception:
     except Exception:
         pass
         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:
     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:
     except Exception:
         pass
         pass
-
-    # Migration: Add show_on_printer_card column to smart_plugs
     try:
     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:
     except Exception:
         pass
         pass
-
-    # Migration: Add sliced_for_model column to print_archives for printer model from 3MF
     try:
     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:
     except Exception:
         pass
         pass
-
-    # Migration: Add target_model column to print_queue for model-based assignment
     try:
     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:
     except Exception:
         pass
         pass
-
-    # Migration: Add required_filament_types column to print_queue for filament validation
     try:
     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:
     except Exception:
         pass
         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:
     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:
     except Exception:
         pass
         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():
 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:
 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 HA plugs, configures the service with current settings from DB.
+    For MQTT plugs, returns data from the subscription service.
     """
     """
     if plug.plug_type == "homeassistant":
     if plug.plug_type == "homeassistant":
         from backend.app.api.routes.settings import get_setting
         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 ""
         ha_token = await get_setting(db, "ha_token") or ""
         homeassistant_service.configure(ha_url, ha_token)
         homeassistant_service.configure(ha_url, ha_token)
         return await homeassistant_service.get_energy(plug)
         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:
     else:
         return await tasmota_service.get_energy(plug)
         return await tasmota_service.get_energy(plug)
 
 
@@ -2395,6 +2407,27 @@ async def lifespan(app: FastAPI):
         }
         }
         await mqtt_relay.configure(mqtt_settings)
         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
     # Connect to all active printers
     async with async_session() as db:
     async with async_session() as db:
         await init_printer_connections(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):
 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"
     __tablename__ = "smart_plugs"
 
 
@@ -15,7 +15,7 @@ class SmartPlug(Base):
     name: Mapped[str] = mapped_column(String(100))
     name: Mapped[str] = mapped_column(String(100))
     ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)  # IPv4/IPv6 (required for Tasmota)
     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")
     plug_type: Mapped[str] = mapped_column(String(20), default="tasmota")
     # Home Assistant entity ID (e.g., "switch.printer_plug")
     # Home Assistant entity ID (e.g., "switch.printer_plug")
     ha_entity_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
     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_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
     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
     # Automation settings
     enabled: Mapped[bool] = mapped_column(Boolean, default=True)
     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_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
     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_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
     # Status tracking
     last_state: Mapped[str | None] = mapped_column(String(10), nullable=True)  # "ON"/"OFF"
     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):
 class SmartPlugBase(BaseModel):
     name: str = Field(..., min_length=1, max_length=100)
     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")
     # 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}$")
     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_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_]+$")
     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
     printer_id: int | None = None
     enabled: bool = True
     enabled: bool = True
     auto_on: bool = True
     auto_on: bool = True
@@ -45,6 +69,17 @@ class SmartPlugBase(BaseModel):
             raise ValueError("ip_address is required for Tasmota plugs")
             raise ValueError("ip_address is required for Tasmota plugs")
         if self.plug_type == "homeassistant" and not self.ha_entity_id:
         if self.plug_type == "homeassistant" and not self.ha_entity_id:
             raise ValueError("ha_entity_id is required for Home Assistant plugs")
             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
         return self
 
 
 
 
@@ -54,13 +89,28 @@ class SmartPlugCreate(SmartPlugBase):
 
 
 class SmartPlugUpdate(BaseModel):
 class SmartPlugUpdate(BaseModel):
     name: str | None = None
     name: str | None = None
-    plug_type: Literal["tasmota", "homeassistant"] | None = None
+    plug_type: Literal["tasmota", "homeassistant", "mqtt"] | None = None
     ip_address: str | None = None
     ip_address: str | None = None
     ha_entity_id: str | None = None
     ha_entity_id: str | None = None
     # Home Assistant energy sensor entities (optional)
     # Home Assistant energy sensor entities (optional)
     ha_power_entity: str | None = None
     ha_power_entity: str | None = None
     ha_energy_today_entity: str | None = None
     ha_energy_today_entity: str | None = None
     ha_energy_total_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
     printer_id: int | None = None
     enabled: bool | None = None
     enabled: bool | None = None
     auto_on: 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._broker = ""
         self._port = 1883
         self._port = 1883
         self._last_printer_status: dict[int, float] = {}  # printer_id -> last publish timestamp
         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:
     async def configure(self, settings: dict) -> bool:
         """Configure MQTT connection from settings.
         """Configure MQTT connection from settings.
@@ -41,9 +43,12 @@ class MQTTRelayService:
         Returns True if connection was successful or MQTT is disabled.
         Returns True if connection was successful or MQTT is disabled.
         """
         """
         self.enabled = settings.get("mqtt_enabled", False)
         self.enabled = settings.get("mqtt_enabled", False)
+        self._settings = settings  # Store for smart plug service
 
 
         if not self.enabled:
         if not self.enabled:
             await self.disconnect()
             await self.disconnect()
+            # Also configure smart plug service (will disable it)
+            await self._configure_smart_plug_service(settings)
             logger.info("MQTT relay disabled")
             logger.info("MQTT relay disabled")
             return True
             return True
 
 
@@ -67,7 +72,33 @@ class MQTTRelayService:
             await self.disconnect()
             await self.disconnect()
 
 
         # Create and connect client
         # 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:
     async def _connect(self, broker: str, port: int, username: str, password: str, use_tls: bool) -> bool:
         """Establish MQTT connection."""
         """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
         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
 @pytest.fixture
 def mock_ftp_client():
 def mock_ftp_client():
     """Mock the FTP client for file transfer tests."""
     """Mock the FTP client for file transfer tests."""
@@ -296,6 +314,22 @@ def smart_plug_factory(db_session):
         if plug_type == "homeassistant":
         if plug_type == "homeassistant":
             defaults["ha_entity_id"] = "switch.test"
             defaults["ha_entity_id"] = "switch.test"
             defaults["ip_address"] = None
             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:
         else:
             defaults["ip_address"] = "192.168.1.100"
             defaults["ip_address"] = "192.168.1.100"
             defaults["ha_entity_id"] = None
             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 response.status_code == 400
         assert "not configured" in response.json()["detail"].lower()
         assert "not configured" in response.json()["detail"].lower()
 
 
+    # ========================================================================
+    # MQTT Integration tests
+    # ========================================================================
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @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 = {
         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,
             "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)
         response = await async_client.post("/api/v1/smart-plugs/", json=data)
 
 
         assert response.status_code == 200
         assert response.status_code == 200
         result = response.json()
         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.asyncio
     @pytest.mark.integration
     @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.asyncio
     @pytest.mark.integration
     @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 = {
         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,
             "enabled": True,
         }
         }
 
 
@@ -633,140 +639,189 @@ class TestSmartPlugsAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @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,
             "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
         assert response.status_code == 200
         result = response.json()
         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.asyncio
     @pytest.mark.integration
     @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 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.asyncio
     @pytest.mark.integration
     @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
         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.asyncio
     @pytest.mark.integration
     @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
         assert response.status_code == 200
         result = response.json()
         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',
   plug_type: 'tasmota',
   ip_address: '192.168.1.100',
   ip_address: '192.168.1.100',
   ha_entity_id: null,
   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,
   printer_id: 1,
   enabled: true,
   enabled: true,
   auto_on: true,
   auto_on: true,
@@ -272,4 +290,74 @@ describe('SmartPlugCard', () => {
       expect(buttons.length).toBeGreaterThan(0);
       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 {
 export interface SmartPlug {
   id: number;
   id: number;
   name: string;
   name: string;
-  plug_type: 'tasmota' | 'homeassistant';
+  plug_type: 'tasmota' | 'homeassistant' | 'mqtt';
   ip_address: string | null;  // Required for Tasmota
   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")
   ha_entity_id: string | null;  // Required for Home Assistant (e.g., "switch.printer_plug", "script.turn_on_printer")
   // Home Assistant energy sensor entities (optional)
   // Home Assistant energy sensor entities (optional)
   ha_power_entity: string | null;
   ha_power_entity: string | null;
   ha_energy_today_entity: string | null;
   ha_energy_today_entity: string | null;
   ha_energy_total_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;
   printer_id: number | null;
   enabled: boolean;
   enabled: boolean;
   auto_on: boolean;
   auto_on: boolean;
@@ -884,13 +900,29 @@ export interface SmartPlug {
 
 
 export interface SmartPlugCreate {
 export interface SmartPlugCreate {
   name: string;
   name: string;
-  plug_type?: 'tasmota' | 'homeassistant';
+  plug_type?: 'tasmota' | 'homeassistant' | 'mqtt';
   ip_address?: string | null;  // Required for Tasmota
   ip_address?: string | null;  // Required for Tasmota
   ha_entity_id?: string | null;  // Required for Home Assistant
   ha_entity_id?: string | null;  // Required for Home Assistant
   // Home Assistant energy sensor entities (optional)
   // Home Assistant energy sensor entities (optional)
   ha_power_entity?: string | null;
   ha_power_entity?: string | null;
   ha_energy_today_entity?: string | null;
   ha_energy_today_entity?: string | null;
   ha_energy_total_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;
   printer_id?: number | null;
   enabled?: boolean;
   enabled?: boolean;
   auto_on?: boolean;
   auto_on?: boolean;
@@ -915,13 +947,28 @@ export interface SmartPlugCreate {
 
 
 export interface SmartPlugUpdate {
 export interface SmartPlugUpdate {
   name?: string;
   name?: string;
-  plug_type?: 'tasmota' | 'homeassistant';
+  plug_type?: 'tasmota' | 'homeassistant' | 'mqtt';
   ip_address?: string | null;
   ip_address?: string | null;
   ha_entity_id?: string | null;
   ha_entity_id?: string | null;
   // Home Assistant energy sensor entities (optional)
   // Home Assistant energy sensor entities (optional)
   ha_power_entity?: string | null;
   ha_power_entity?: string | null;
   ha_energy_today_entity?: string | null;
   ha_energy_today_entity?: string | null;
   ha_energy_total_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;
   printer_id?: number | null;
   enabled?: boolean;
   enabled?: boolean;
   auto_on?: boolean;
   auto_on?: boolean;

+ 290 - 177
frontend/src/components/AddSmartPlugModal.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useRef } from 'react';
 import { useState, useEffect, useRef } from 'react';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
 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 { api } from '../api/client';
 import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate, DiscoveredTasmotaDevice } from '../api/client';
 import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate, DiscoveredTasmotaDevice } from '../api/client';
 import { Button } from './Button';
 import { Button } from './Button';
@@ -15,7 +15,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
   const isEditing = !!plug;
   const isEditing = !!plug;
 
 
   // Plug type selection
   // 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 || '');
   const [name, setName] = useState(plug?.name || '');
   // Tasmota fields
   // Tasmota fields
@@ -24,6 +24,22 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
   const [password, setPassword] = useState(plug?.password || '');
   const [password, setPassword] = useState(plug?.password || '');
   // Home Assistant fields
   // Home Assistant fields
   const [haEntityId, setHaEntityId] = useState(plug?.ha_entity_id || '');
   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)
   // HA energy sensor entities (optional)
   const [haPowerEntity, setHaPowerEntity] = useState(plug?.ha_power_entity || '');
   const [haPowerEntity, setHaPowerEntity] = useState(plug?.ha_power_entity || '');
   const [haEnergyTodayEntity, setHaEnergyTodayEntity] = useState(plug?.ha_energy_today_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 [testResult, setTestResult] = useState<{ success: boolean; state?: string | null; device_name?: string | null } | null>(null);
   const [error, setError] = useState<string | 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
   // Power alert settings
   const [powerAlertEnabled, setPowerAlertEnabled] = useState(plug?.power_alert_enabled || false);
   const [powerAlertEnabled, setPowerAlertEnabled] = useState(plug?.power_alert_enabled || false);
   const [powerAlertHigh, setPowerAlertHigh] = useState<string>(plug?.power_alert_high?.toString() || '');
   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 [scheduleOnTime, setScheduleOnTime] = useState<string>(plug?.schedule_on_time || '');
   const [scheduleOffTime, setScheduleOffTime] = useState<string>(plug?.schedule_off_time || '');
   const [scheduleOffTime, setScheduleOffTime] = useState<string>(plug?.schedule_off_time || '');
 
 
-  // Visibility options
+  // Switchbar visibility
   const [showInSwitchbar, setShowInSwitchbar] = useState(plug?.show_in_switchbar || false);
   const [showInSwitchbar, setShowInSwitchbar] = useState(plug?.show_in_switchbar || false);
-  const [showOnPrinterCard, setShowOnPrinterCard] = useState(plug?.show_on_printer_card ?? true);
 
 
   // Discovery state
   // Discovery state
   const [isScanning, setIsScanning] = useState(false);
   const [isScanning, setIsScanning] = useState(false);
@@ -240,10 +251,6 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
     mutationFn: (data: SmartPlugCreate) => api.createSmartPlug(data),
     mutationFn: (data: SmartPlugCreate) => api.createSmartPlug(data),
     onSuccess: () => {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
       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();
       onClose();
     },
     },
     onError: (err: Error) => {
     onError: (err: Error) => {
@@ -256,10 +263,6 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
     mutationFn: (data: SmartPlugUpdate) => api.updateSmartPlug(plug!.id, data),
     mutationFn: (data: SmartPlugUpdate) => api.updateSmartPlug(plug!.id, data),
     onSuccess: () => {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
       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();
       onClose();
     },
     },
     onError: (err: Error) => {
     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)
   // 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) => {
   const handleSubmit = (e: React.FormEvent) => {
     e.preventDefault();
     e.preventDefault();
@@ -298,6 +295,18 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
       return;
       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 = {
     const data = {
       name: name.trim(),
       name: name.trim(),
       plug_type: plugType,
       plug_type: plugType,
@@ -307,12 +316,21 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
       ha_power_entity: plugType === 'homeassistant' ? (haPowerEntity || null) : null,
       ha_power_entity: plugType === 'homeassistant' ? (haPowerEntity || null) : null,
       ha_energy_today_entity: plugType === 'homeassistant' ? (haEnergyTodayEntity || null) : null,
       ha_energy_today_entity: plugType === 'homeassistant' ? (haEnergyTodayEntity || null) : null,
       ha_energy_total_entity: plugType === 'homeassistant' ? (haEnergyTotalEntity || 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,
       username: plugType === 'tasmota' ? (username.trim() || null) : null,
       password: plugType === 'tasmota' ? (password.trim() || null) : null,
       password: plugType === 'tasmota' ? (password.trim() || null) : null,
       printer_id: printerId,
       printer_id: printerId,
-      // Automation
-      auto_on: autoOn,
-      auto_off: autoOff,
       // Power alerts
       // Power alerts
       power_alert_enabled: powerAlertEnabled,
       power_alert_enabled: powerAlertEnabled,
       power_alert_high: powerAlertHigh ? parseFloat(powerAlertHigh) : null,
       power_alert_high: powerAlertHigh ? parseFloat(powerAlertHigh) : null,
@@ -321,9 +339,8 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
       schedule_enabled: scheduleEnabled,
       schedule_enabled: scheduleEnabled,
       schedule_on_time: scheduleOnTime || null,
       schedule_on_time: scheduleOnTime || null,
       schedule_off_time: scheduleOffTime || null,
       schedule_off_time: scheduleOffTime || null,
-      // Visibility options
+      // Switchbar
       show_in_switchbar: showInSwitchbar,
       show_in_switchbar: showInSwitchbar,
-      show_on_printer_card: showOnPrinterCard,
     };
     };
 
 
     if (isEditing) {
     if (isEditing) {
@@ -375,7 +392,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
                   setTestResult(null);
                   setTestResult(null);
                   setError(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'
                   plugType === 'tasmota'
                     ? 'bg-bambu-green text-white'
                     ? 'bg-bambu-green text-white'
                     : 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary'
                     : '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);
                   setTestResult(null);
                   setError(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'
                   plugType === 'homeassistant'
                     ? 'bg-bambu-green text-white'
                     ? 'bg-bambu-green text-white'
                     : 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary'
                     : 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary'
                 }`}
                 }`}
               >
               >
                 <Home className="w-4 h-4" />
                 <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>
               </button>
             </div>
             </div>
           )}
           )}
@@ -605,15 +638,15 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
                         <p className="text-xs text-bambu-gray mt-1">
                         <p className="text-xs text-bambu-gray mt-1">
                           {debouncedSearch
                           {debouncedSearch
                             ? `Searching all entities (${availableEntities.length} found)`
                             ? `Searching all entities (${availableEntities.length} found)`
-                            : `Showing switch, light, input_boolean, script (${availableEntities.length} available)`}
+                            : `Showing switch, light, input_boolean (${availableEntities.length} available)`}
                         </p>
                         </p>
                       </div>
                       </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 className="border-t border-bambu-dark-tertiary pt-4 mt-4 space-y-3">
                       <div>
                       <div>
                         <p className="text-white font-medium mb-1">Energy Monitoring (Optional)</p>
                         <p className="text-white font-medium mb-1">Energy Monitoring (Optional)</p>
@@ -880,6 +913,155 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
             </div>
             </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 */}
           {/* IP Address - only show for Tasmota */}
           {plugType === 'tasmota' && (
           {plugType === 'tasmota' && (
             <div>
             <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>
             </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>
               </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>
                   </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>
-              )}
-            </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="border-t border-bambu-dark-tertiary pt-4">
               <div className="flex items-center justify-between mb-3">
               <div className="flex items-center justify-between mb-3">
                 <div className="flex items-center gap-2">
                 <div className="flex items-center gap-2">
@@ -1150,31 +1286,8 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
             </div>
             </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 justify-between">
               <div className="flex items-center gap-2">
               <div className="flex items-center gap-2">
                 <LayoutGrid className="w-4 h-4 text-bambu-green" />
                 <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 { useState } from 'react';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
 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 { api } from '../api/client';
 import type { SmartPlug, SmartPlugUpdate } from '../api/client';
 import type { SmartPlug, SmartPlugUpdate } from '../api/client';
 import { Card, CardContent } from './Card';
 import { Card, CardContent } from './Card';
@@ -55,24 +55,12 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
 
 
       return { previousStatus };
       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) => {
     onError: (_err, action, context) => {
       // Rollback on error
       // Rollback on error
       if (context?.previousStatus) {
       if (context?.previousStatus) {
         queryClient.setQueryData(['smart-plug-status', plug.id], 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: () => {
     onSettled: () => {
       // Refetch after a short delay to get actual state
       // Refetch after a short delay to get actual state
@@ -92,10 +80,6 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
       if (plug.printer_id) {
       if (plug.printer_id) {
         queryClient.invalidateQueries({ queryKey: ['smartPlugByPrinter', 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),
     mutationFn: () => api.deleteSmartPlug(plug.id),
     onSuccess: () => {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
       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 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;
   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)
   // Generate admin URL with auto-login credentials (Tasmota only)
   const getAdminUrl = () => {
   const getAdminUrl = () => {
     if (plug.plug_type !== 'tasmota' || !plug.ip_address) return null;
     if (plug.plug_type !== 'tasmota' || !plug.ip_address) return null;
@@ -138,9 +117,13 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
           {/* Header Row */}
           {/* Header Row */}
           <div className="flex items-start justify-between gap-2 mb-3">
           <div className="flex items-start justify-between gap-2 mb-3">
             <div className="flex items-center gap-3 min-w-0 flex-1">
             <div className="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' ? (
                 ) : plug.plug_type === 'homeassistant' ? (
                   <Home className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
                   <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>
               <div className="min-w-0">
               <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>
                 </p>
               </div>
               </div>
             </div>
             </div>
@@ -159,23 +145,23 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
             <div className="flex flex-col items-end gap-1 flex-shrink-0">
             <div className="flex flex-col items-end gap-1 flex-shrink-0">
               {statusLoading ? (
               {statusLoading ? (
                 <Loader2 className="w-4 h-4 text-bambu-gray animate-spin" />
                 <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>
                   </span>
                 </div>
                 </div>
               ) : isReachable ? (
               ) : isReachable ? (
                 <div className="flex items-center gap-1 text-sm">
                 <div className="flex items-center gap-1 text-sm">
                   <Wifi className="w-4 h-4 text-status-ok" />
                   <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>
               ) : (
               ) : (
                 <div className="flex items-center gap-1 text-sm text-status-error">
                 <div className="flex items-center gap-1 text-sm text-status-error">
@@ -208,8 +194,14 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
           )}
           )}
 
 
           {/* Feature Badges */}
           {/* 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">
             <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 && (
               {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">
                 <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" />
                   <Bell className="w-3 h-3" />
@@ -229,46 +221,49 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
             </div>
             </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
               <Button
                 size="sm"
                 size="sm"
-                variant="primary"
+                variant={isOn ? 'primary' : 'secondary'}
                 disabled={!isReachable || isPending}
                 disabled={!isReachable || isPending}
                 onClick={() => setShowPowerOnConfirm(true)}
                 onClick={() => setShowPowerOnConfirm(true)}
                 className="flex-1"
                 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>
               </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 */}
           {/* Toggle Settings Panel */}
           <button
           <button
@@ -277,7 +272,7 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
           >
           >
             <span className="flex items-center gap-2">
             <span className="flex items-center gap-2">
               <Settings2 className="w-4 h-4" />
               <Settings2 className="w-4 h-4" />
-              Automation Settings
+              {plug.plug_type === 'mqtt' ? 'Settings' : 'Automation Settings'}
             </span>
             </span>
             <span>{isExpanded ? '-' : '+'}</span>
             <span>{isExpanded ? '-' : '+'}</span>
           </button>
           </button>
@@ -285,28 +280,6 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
           {/* Expanded Settings */}
           {/* Expanded Settings */}
           {isExpanded && (
           {isExpanded && (
             <div className="pt-3 border-t border-bambu-dark-tertiary space-y-4">
             <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 */}
               {/* Show in Switchbar Toggle */}
               <div className="flex items-center justify-between">
               <div className="flex items-center justify-between">
                 <div className="flex items-center gap-2">
                 <div className="flex items-center gap-2">
@@ -327,49 +300,48 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
                 </label>
                 </label>
               </div>
               </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>
                 </div>
                 <label className="relative inline-flex items-center cursor-pointer">
                 <label className="relative inline-flex items-center cursor-pointer">
                   <input
                   <input
@@ -382,8 +354,8 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
                 </label>
                 </label>
               </div>
               </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 className="space-y-3 pl-4 border-l-2 border-bambu-dark-tertiary">
                   <div>
                   <div>
                     <p className="text-sm text-white mb-2">Turn Off Delay Mode</p>
                     <p className="text-sm text-white mb-2">Turn Off Delay Mode</p>
@@ -441,6 +413,8 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
                   )}
                   )}
                 </div>
                 </div>
               )}
               )}
+                </>
+              )}
 
 
               {/* Action Buttons */}
               {/* Action Buttons */}
               <div className="flex gap-2 pt-2">
               <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 && (
       {showPowerOnConfirm && (
         <ConfirmModal
         <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"
           variant="default"
           onConfirm={() => {
           onConfirm={() => {
             controlMutation.mutate('on');
             controlMutation.mutate('on');

+ 57 - 64
frontend/src/components/SwitchbarPopover.tsx

@@ -1,6 +1,6 @@
 import { useState } from 'react';
 import { useState } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 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 { api } from '../api/client';
 import type { SmartPlug } from '../api/client';
 import type { SmartPlug } from '../api/client';
 import { ConfirmModal } from './ConfirmModal';
 import { ConfirmModal } from './ConfirmModal';
@@ -29,11 +29,11 @@ function SwitchItem({ plug }: { plug: SmartPlug }) {
   });
   });
 
 
   const isOn = status?.state === 'ON';
   const isOn = status?.state === 'ON';
-  const isReachable = status?.reachable ?? false;
+  // For MQTT plugs, consider reachable if we have power data
+  const hasMqttData = plug.plug_type === 'mqtt' && (status?.energy?.power !== null && status?.energy?.power !== undefined);
+  const isReachable = (status?.reachable ?? false) || hasMqttData;
   const isPending = controlMutation.isPending;
   const 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 = () => {
   const handleConfirm = () => {
     if (confirmAction) {
     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 justify-between py-2 px-3 hover:bg-bambu-dark-tertiary rounded-lg transition-colors">
         <div className="flex items-center gap-2">
         <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'}`} />
               <Plug className={`w-4 h-4 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
             )}
             )}
           </div>
           </div>
           <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">
             <div className="flex items-center gap-1 text-xs">
               {statusLoading ? (
               {statusLoading ? (
                 <Loader2 className="w-3 h-3 text-bambu-gray animate-spin" />
                 <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 ? (
               ) : isReachable ? (
                 <>
                 <>
                   <Wifi className="w-3 h-3 text-status-ok" />
                   <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 && (
                   {status?.energy?.power !== null && status?.energy?.power !== undefined && (
                     <>
                     <>
                       <span className="text-bambu-gray mx-1">|</span>
                       <span className="text-bambu-gray mx-1">|</span>
@@ -91,58 +100,42 @@ function SwitchItem({ plug }: { plug: SmartPlug }) {
           </div>
           </div>
         </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
             <button
               onClick={() => setConfirmAction('on')}
               onClick={() => setConfirmAction('on')}
               disabled={!isReachable || isPending}
               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>
             </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>
       </div>
 
 
       {confirmAction && (
       {confirmAction && (
         <ConfirmModal
         <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'}
           variant={confirmAction === 'off' ? 'warning' : 'default'}
           onConfirm={handleConfirm}
           onConfirm={handleConfirm}
           onCancel={() => setConfirmAction(null)}
           onCancel={() => setConfirmAction(null)}

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

@@ -138,13 +138,17 @@ export function SettingsPage() {
       let totalLifetime = 0;
       let totalLifetime = 0;
       let reachableCount = 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++;
           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;
         }
         }
       }
       }
 
 

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-BJQC4Kk-.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-BrclLX7E.css


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-C1mIxrzF.css


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-Dm1kl7bT.js


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <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>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio