Quellcode durchsuchen

Add MQTT smart plug support for energy monitoring (Issue #173)

Add support for MQTT-based smart plugs that subscribe to external MQTT
topics and extract power/energy data from JSON payloads. This enables
integration with Zigbee2MQTT, Shelly, Tasmota discovery, and other
MQTT-enabled energy monitoring devices.

Features:
- New "mqtt" plug type alongside tasmota and homeassistant
- Subscribe to any MQTT topic with configurable JSON paths
- Extract power, energy, and state values using dot notation
- Optional multiplier for unit conversion (mW to W, etc.)
- Monitor-only mode (no on/off control) with teal color scheme
- Reuses existing MQTT broker settings from network configuration
- Energy data included in statistics and per-print tracking
- Full backup/restore support for MQTT plug configurations

Closes #173
maziggy vor 7 Monaten
Ursprung
Commit
00e3478a3e

+ 9 - 0
CHANGELOG.md

@@ -5,6 +5,15 @@ 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
+- **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.)
+  - Configurable JSON paths for power, energy, and state extraction (e.g., `power_l1`, `data.power`)
+  - Optional multiplier for unit conversion (mW to W, etc.)
+  - 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

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

@@ -514,6 +514,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))
@@ -521,9 +523,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)

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

@@ -360,6 +360,12 @@ 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
+                    "mqtt_topic": plug.mqtt_topic,
+                    "mqtt_power_path": plug.mqtt_power_path,
+                    "mqtt_energy_path": plug.mqtt_energy_path,
+                    "mqtt_state_path": plug.mqtt_state_path,
+                    "mqtt_multiplier": plug.mqtt_multiplier,
                     "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,
@@ -1257,12 +1263,17 @@ 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_topic"):
+                result = await db.execute(select(SmartPlug).where(SmartPlug.mqtt_topic == plug_data["mqtt_topic"]))
+                existing = result.scalar_one_or_none()
+                plug_identifier = plug_data["mqtt_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()
@@ -1279,6 +1290,12 @@ 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
+                    existing.mqtt_topic = plug_data.get("mqtt_topic")
+                    existing.mqtt_power_path = plug_data.get("mqtt_power_path")
+                    existing.mqtt_energy_path = plug_data.get("mqtt_energy_path")
+                    existing.mqtt_state_path = plug_data.get("mqtt_state_path")
+                    existing.mqtt_multiplier = plug_data.get("mqtt_multiplier", 1.0)
                     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)
@@ -1308,6 +1325,12 @@ 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
+                    mqtt_topic=plug_data.get("mqtt_topic"),
+                    mqtt_power_path=plug_data.get("mqtt_power_path"),
+                    mqtt_energy_path=plug_data.get("mqtt_energy_path"),
+                    mqtt_state_path=plug_data.get("mqtt_state_path"),
+                    mqtt_multiplier=plug_data.get("mqtt_multiplier", 1.0),
                     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),

+ 119 - 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
@@ -60,12 +61,53 @@ async def create_smart_plug(
         if result.scalar_one_or_none():
         if result.scalar_one_or_none():
             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 topic
+    if plug.plug_type == "mqtt" and 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,
+        )
+        logger.info(f"Created MQTT plug '{plug.name}' subscribed to {plug.mqtt_topic}")
+    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}")
@@ -296,12 +338,37 @@ async def update_smart_plug(
         if result.scalar_one_or_none():
         if result.scalar_one_or_none():
             raise HTTPException(400, "This printer already has a smart plug assigned")
             raise HTTPException(400, "This printer already has a smart plug assigned")
 
 
+    # Check if MQTT topic is changing - need to resubscribe
+    old_topic = plug.mqtt_topic
+    old_plug_type = plug.plug_type
+
     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":
+        # Is now MQTT - check if topic changed or newly MQTT
+        if old_plug_type != "mqtt" or old_topic != plug.mqtt_topic:
+            # Unsubscribe from old topic first
+            if old_plug_type == "mqtt":
+                mqtt_relay.smart_plug_service.unsubscribe(plug.id)
+            # Subscribe to new topic
+            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,
+                )
+
     logger.info(f"Updated smart plug '{plug.name}'")
     logger.info(f"Updated smart plug '{plug.name}'")
     return plug
     return plug
 
 
@@ -315,6 +382,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()
 
 
@@ -348,6 +421,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":
@@ -409,6 +489,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)
 
 

+ 22 - 0
backend/app/core/database.py

@@ -760,6 +760,28 @@ async def run_migrations(conn):
     except Exception:
     except Exception:
         pass
         pass
 
 
+    # Migration: Add MQTT smart plug fields
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_topic VARCHAR(200)"))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_power_path VARCHAR(100)"))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_path VARCHAR(100)"))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_state_path VARCHAR(100)"))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_multiplier REAL DEFAULT 1.0"))
+    except Exception:
+        pass
+
 
 
 async def seed_notification_templates():
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""
     """Seed default notification templates if they don't exist."""

+ 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)
 
 
@@ -2367,6 +2379,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)

+ 11 - 2
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,6 +24,15 @@ 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
 
 
+    # MQTT plug fields (required when plug_type="mqtt")
+    mqtt_topic: Mapped[str | None] = mapped_column(
+        String(200), nullable=True
+    )  # e.g., "zigbee2mqtt/shelly-working-room"
+    mqtt_power_path: Mapped[str | None] = mapped_column(String(100), nullable=True)  # e.g., "power_l1" or "data.power"
+    mqtt_energy_path: Mapped[str | None] = mapped_column(String(100), nullable=True)  # e.g., "energy_l1"
+    mqtt_state_path: Mapped[str | None] = mapped_column(String(100), nullable=True)  # e.g., "state_l1" for ON/OFF
+    mqtt_multiplier: Mapped[float] = mapped_column(Float, default=1.0)  # Unit conversion (e.g., 0.001 for mW→W)
+
     # Link to printer (1:1)
     # Link to printer (1:1)
     printer_id: Mapped[int | None] = mapped_column(
     printer_id: Mapped[int | None] = mapped_column(
         ForeignKey("printers.id", ondelete="SET NULL"), unique=True, nullable=True
         ForeignKey("printers.id", ondelete="SET NULL"), unique=True, nullable=True

+ 20 - 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,13 @@ 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")
+    mqtt_topic: str | None = Field(default=None, max_length=200)
+    mqtt_power_path: str | None = Field(default=None, max_length=100)  # e.g., "power_l1" or "data.power"
+    mqtt_energy_path: str | None = Field(default=None, max_length=100)  # e.g., "energy_l1"
+    mqtt_state_path: str | None = Field(default=None, max_length=100)  # e.g., "state_l1" for ON/OFF
+    mqtt_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)  # Unit conversion (e.g., 0.001 for mW→W)
+
     printer_id: int | None = None
     printer_id: int | None = None
     enabled: bool = True
     enabled: bool = True
     auto_on: bool = True
     auto_on: bool = True
@@ -44,6 +51,11 @@ 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":
+            if not self.mqtt_topic:
+                raise ValueError("mqtt_topic is required for MQTT plugs")
+            if not self.mqtt_power_path and not self.mqtt_state_path:
+                raise ValueError("At least mqtt_power_path or mqtt_state_path is required for MQTT plugs")
         return self
         return self
 
 
 
 
@@ -53,13 +65,19 @@ 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
+    mqtt_topic: str | None = None
+    mqtt_power_path: str | None = None
+    mqtt_energy_path: str | None = None
+    mqtt_state_path: str | None = None
+    mqtt_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
     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."""

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

@@ -0,0 +1,401 @@
+"""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)
+
+
+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_ids (multiple plugs can subscribe to same topic with different paths)
+        self.subscriptions: dict[str, list[int]] = {}
+        # plug_id -> (topic, power_path, energy_path, state_path, multiplier)
+        self.plug_configs: dict[int, tuple[str, str | None, str | None, str | None, float]] = {}
+        # 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:
+            plug_ids = self.subscriptions.get(topic, [])
+            if not plug_ids:
+                return
+
+            # Parse JSON payload
+            try:
+                payload = json.loads(msg.payload.decode("utf-8"))
+            except (json.JSONDecodeError, UnicodeDecodeError) as e:
+                logger.debug(f"MQTT smart plug: failed to parse message on {topic}: {e}")
+                return
+
+            # Process for each subscribed plug
+            for plug_id in plug_ids:
+                config = self.plug_configs.get(plug_id)
+                if not config:
+                    continue
+
+                _, power_path, energy_path, state_path, multiplier = config
+
+                # Extract values
+                power = None
+                energy = None
+                state = None
+
+                if power_path:
+                    raw_power = self._extract_json_path(payload, power_path)
+                    if raw_power is not None:
+                        try:
+                            power = float(raw_power) * multiplier
+                        except (ValueError, TypeError):
+                            pass
+
+                if energy_path:
+                    raw_energy = self._extract_json_path(payload, energy_path)
+                    if raw_energy is not None:
+                        try:
+                            energy = float(raw_energy) * multiplier
+                        except (ValueError, TypeError):
+                            pass
+
+                if state_path:
+                    raw_state = self._extract_json_path(payload, state_path)
+                    if raw_state is not None:
+                        # Normalize state to ON/OFF
+                        state_str = str(raw_state).upper()
+                        if state_str in ("ON", "1", "TRUE"):
+                            state = "ON"
+                        elif state_str in ("OFF", "0", "FALSE"):
+                            state = "OFF"
+                        else:
+                            state = state_str
+
+                # Update plug data
+                if plug_id in self.plug_data:
+                    data = self.plug_data[plug_id]
+                    if power is not None:
+                        data.power = power
+                    if energy is not None:
+                        data.energy = energy
+                    if state is not None:
+                        data.state = state
+                    data.last_seen = datetime.utcnow()
+                else:
+                    self.plug_data[plug_id] = SmartPlugMQTTData(
+                        plug_id=plug_id,
+                        power=power,
+                        energy=energy,
+                        state=state,
+                        last_seen=datetime.utcnow(),
+                    )
+
+                logger.debug(f"MQTT smart plug {plug_id}: power={power}, energy={energy}, state={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:
+                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,
+        topic: str,
+        power_path: str | None = None,
+        energy_path: str | None = None,
+        state_path: str | None = None,
+        multiplier: float = 1.0,
+    ):
+        """Subscribe to a topic for a plug."""
+        with self._lock:
+            # Store configuration
+            self.plug_configs[plug_id] = (topic, power_path, energy_path, state_path, multiplier)
+
+            # Add to subscriptions
+            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 {plug_id}: subscribed to {topic}")
+                    except Exception as e:
+                        logger.error(f"MQTT smart plug: failed to subscribe to {topic}: {e}")
+
+            if plug_id not in self.subscriptions[topic]:
+                self.subscriptions[topic].append(plug_id)
+
+            # Initialize data entry
+            if plug_id not in self.plug_data:
+                self.plug_data[plug_id] = SmartPlugMQTTData(plug_id=plug_id)
+
+    def unsubscribe(self, plug_id: int):
+        """Unsubscribe when plug is deleted/updated."""
+        with self._lock:
+            # Get the topic for this plug
+            config = self.plug_configs.pop(plug_id, None)
+            if not config:
+                return
+
+            topic = config[0]
+
+            # Remove from subscriptions
+            if topic in self.subscriptions:
+                if plug_id in self.subscriptions[topic]:
+                    self.subscriptions[topic].remove(plug_id)
+
+                # If no more plugs on this topic, unsubscribe
+                if 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()

+ 24 - 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,12 @@ 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":
+            defaults["mqtt_topic"] = kwargs.get("mqtt_topic", "test/topic")
+            defaults["mqtt_power_path"] = kwargs.get("mqtt_power_path", "power")
+            defaults["mqtt_multiplier"] = kwargs.get("mqtt_multiplier", 1.0)
+            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

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

@@ -573,3 +573,124 @@ 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.integration
+    async def test_create_mqtt_plug(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):
+        """Verify MQTT plug can be created with topic and JSON paths."""
+        data = {
+            "name": "MQTT Energy Monitor",
+            "plug_type": "mqtt",
+            "mqtt_topic": "zigbee2mqtt/shelly-working-room",
+            "mqtt_power_path": "power_l1",
+            "mqtt_energy_path": "energy_l1",
+            "mqtt_state_path": "state_l1",
+            "mqtt_multiplier": 1.0,
+            "enabled": True,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["name"] == "MQTT Energy Monitor"
+        assert result["plug_type"] == "mqtt"
+        assert result["mqtt_topic"] == "zigbee2mqtt/shelly-working-room"
+        assert result["mqtt_power_path"] == "power_l1"
+        assert result["mqtt_energy_path"] == "energy_l1"
+        assert result["mqtt_state_path"] == "state_l1"
+        assert result["mqtt_multiplier"] == 1.0
+        assert result["ip_address"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_mqtt_plug_missing_topic(self, async_client: AsyncClient):
+        """Verify creating MQTT plug without topic fails."""
+        data = {
+            "name": "MQTT Plug",
+            "plug_type": "mqtt",
+            # Missing mqtt_topic
+            "mqtt_power_path": "power",
+            "enabled": True,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
+
+        assert response.status_code == 422  # Validation error
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_mqtt_plug_missing_paths(self, async_client: AsyncClient):
+        """Verify creating MQTT plug without any JSON paths fails."""
+        data = {
+            "name": "MQTT Plug",
+            "plug_type": "mqtt",
+            "mqtt_topic": "test/topic",
+            # Missing both mqtt_power_path and mqtt_state_path
+            "enabled": True,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
+
+        assert response.status_code == 422  # Validation error
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_mqtt_plug_with_multiplier(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):
+        """Verify MQTT plug can use multiplier for unit conversion."""
+        data = {
+            "name": "MQTT mW to W",
+            "plug_type": "mqtt",
+            "mqtt_topic": "sensors/power",
+            "mqtt_power_path": "power_mw",
+            "mqtt_multiplier": 0.001,  # Convert mW to W
+            "enabled": True,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["mqtt_multiplier"] == 0.001
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_control_mqtt_plug_returns_error(self, async_client: AsyncClient, smart_plug_factory, db_session):
+        """Verify MQTT plugs cannot be controlled (monitor-only)."""
+        plug = await smart_plug_factory(
+            plug_type="mqtt",
+            mqtt_topic="test/topic",
+            mqtt_power_path="power",
+        )
+
+        response = await async_client.post(f"/api/v1/smart-plugs/{plug.id}/control", json={"action": "on"})
+
+        assert response.status_code == 400
+        assert "monitor-only" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_mqtt_plug_topic(self, async_client: AsyncClient, smart_plug_factory, db_session):
+        """Verify MQTT plug topic can be updated."""
+        plug = await smart_plug_factory(
+            plug_type="mqtt",
+            mqtt_topic="old/topic",
+            mqtt_power_path="power",
+        )
+
+        response = await async_client.patch(
+            f"/api/v1/smart-plugs/{plug.id}",
+            json={
+                "mqtt_topic": "new/topic",
+                "mqtt_power_path": "new_power",
+            },
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["mqtt_topic"] == "new/topic"
+        assert result["mqtt_power_path"] == "new_power"

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

@@ -21,6 +21,12 @@ 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,
+  // MQTT fields
+  mqtt_topic: null,
+  mqtt_power_path: null,
+  mqtt_energy_path: null,
+  mqtt_state_path: null,
+  mqtt_multiplier: 1.0,
   printer_id: 1,
   printer_id: 1,
   enabled: true,
   enabled: true,
   auto_on: true,
   auto_on: true,
@@ -272,4 +278,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();
+    });
+  });
 });
 });

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

@@ -840,13 +840,19 @@ 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")
   ha_entity_id: string | null;  // Required for Home Assistant (e.g., "switch.printer_plug")
   // 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")
+  mqtt_topic: string | null;  // e.g., "zigbee2mqtt/shelly-working-room"
+  mqtt_power_path: string | null;  // e.g., "power_l1" or "data.power"
+  mqtt_energy_path: string | null;  // e.g., "energy_l1"
+  mqtt_state_path: string | null;  // e.g., "state_l1" for ON/OFF
+  mqtt_multiplier: number;  // Unit conversion (e.g., 0.001 for mW→W)
   printer_id: number | null;
   printer_id: number | null;
   enabled: boolean;
   enabled: boolean;
   auto_on: boolean;
   auto_on: boolean;
@@ -877,13 +883,19 @@ 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")
+  mqtt_topic?: string | null;
+  mqtt_power_path?: string | null;
+  mqtt_energy_path?: string | null;
+  mqtt_state_path?: string | null;
+  mqtt_multiplier?: number;
   printer_id?: number | null;
   printer_id?: number | null;
   enabled?: boolean;
   enabled?: boolean;
   auto_on?: boolean;
   auto_on?: boolean;
@@ -907,13 +919,19 @@ 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
+  mqtt_topic?: string | null;
+  mqtt_power_path?: string | null;
+  mqtt_energy_path?: string | null;
+  mqtt_state_path?: string | null;
+  mqtt_multiplier?: number;
   printer_id?: number | null;
   printer_id?: number | null;
   enabled?: boolean;
   enabled?: boolean;
   auto_on?: boolean;
   auto_on?: boolean;

+ 197 - 66
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 } 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,12 @@ 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
+  const [mqttTopic, setMqttTopic] = useState(plug?.mqtt_topic || '');
+  const [mqttPowerPath, setMqttPowerPath] = useState(plug?.mqtt_power_path || '');
+  const [mqttEnergyPath, setMqttEnergyPath] = useState(plug?.mqtt_energy_path || '');
+  const [mqttStatePath, setMqttStatePath] = useState(plug?.mqtt_state_path || '');
+  const [mqttMultiplier, setMqttMultiplier] = useState<string>(plug?.mqtt_multiplier?.toString() || '1');
   // 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 || '');
@@ -279,6 +285,17 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
       return;
       return;
     }
     }
 
 
+    if (plugType === 'mqtt') {
+      if (!mqttTopic.trim()) {
+        setError('MQTT topic is required for MQTT plugs');
+        return;
+      }
+      if (!mqttPowerPath.trim() && !mqttStatePath.trim()) {
+        setError('At least power path or state path is required for MQTT plugs');
+        return;
+      }
+    }
+
     const data = {
     const data = {
       name: name.trim(),
       name: name.trim(),
       plug_type: plugType,
       plug_type: plugType,
@@ -288,6 +305,12 @@ 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 fields
+      mqtt_topic: plugType === 'mqtt' ? mqttTopic.trim() : null,
+      mqtt_power_path: plugType === 'mqtt' ? (mqttPowerPath.trim() || null) : null,
+      mqtt_energy_path: plugType === 'mqtt' ? (mqttEnergyPath.trim() || null) : null,
+      mqtt_state_path: plugType === 'mqtt' ? (mqttStatePath.trim() || null) : null,
+      mqtt_multiplier: plugType === 'mqtt' ? (parseFloat(mqttMultiplier) || 1) : 1,
       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,
@@ -352,7 +375,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'
@@ -368,14 +391,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>
           )}
           )}
@@ -857,6 +896,94 @@ 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>
+
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">MQTT Topic *</label>
+                    <input
+                      type="text"
+                      value={mqttTopic}
+                      onChange={(e) => setMqttTopic(e.target.value)}
+                      placeholder="zigbee2mqtt/shelly-working-room"
+                      className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                    />
+                    <p className="text-xs text-bambu-gray mt-1">The MQTT topic to subscribe to</p>
+                  </div>
+
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">Power JSON Path *</label>
+                    <input
+                      type="text"
+                      value={mqttPowerPath}
+                      onChange={(e) => setMqttPowerPath(e.target.value)}
+                      placeholder="power_l1 or data.power"
+                      className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                    />
+                    <p className="text-xs text-bambu-gray mt-1">Path to power value in JSON (dot notation)</p>
+                  </div>
+
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">State 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 border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                    />
+                    <p className="text-xs text-bambu-gray mt-1">Path to ON/OFF state (optional)</p>
+                  </div>
+
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">Energy 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 border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                    />
+                    <p className="text-xs text-bambu-gray mt-1">Path to energy/kWh value (optional)</p>
+                  </div>
+
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">Multiplier</label>
+                    <input
+                      type="text"
+                      value={mqttMultiplier}
+                      onChange={(e) => setMqttMultiplier(e.target.value)}
+                      placeholder="1"
+                      className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                    />
+                    <p className="text-xs text-bambu-gray mt-1">
+                      Multiply values by this factor. Use 0.001 to convert mW to W, 1000 for kW to W.
+                    </p>
+                  </div>
+                </>
+              )}
+            </div>
+          )}
+
           {/* IP Address - only show for Tasmota */}
           {/* IP Address - only show for Tasmota */}
           {plugType === 'tasmota' && (
           {plugType === 'tasmota' && (
             <div>
             <div>
@@ -959,25 +1086,27 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
             </>
             </>
           )}
           )}
 
 
-          {/* Link to Printer */}
-          <div>
-            <label className="block text-sm text-bambu-gray mb-1">Link to Printer</label>
-            <select
-              value={printerId ?? ''}
-              onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
-              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-            >
-              <option value="">No printer (manual control only)</option>
-              {availablePrinters?.map((p) => (
-                <option key={p.id} value={p.id}>
-                  {p.name}
-                </option>
-              ))}
-            </select>
-            <p className="text-xs text-bambu-gray mt-1">
-              Linking enables automatic on/off when prints start/complete
-            </p>
-          </div>
+          {/* Link to Printer - not shown for MQTT plugs (monitor-only) */}
+          {plugType !== 'mqtt' && (
+            <div>
+              <label className="block text-sm text-bambu-gray mb-1">Link to Printer</label>
+              <select
+                value={printerId ?? ''}
+                onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
+                className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+              >
+                <option value="">No printer (manual control only)</option>
+                {availablePrinters?.map((p) => (
+                  <option key={p.id} value={p.id}>
+                    {p.name}
+                  </option>
+                ))}
+              </select>
+              <p className="text-xs text-bambu-gray mt-1">
+                Linking enables automatic on/off when prints start/complete
+              </p>
+            </div>
+          )}
 
 
           {/* Power Alerts */}
           {/* Power Alerts */}
           <div className="border-t border-bambu-dark-tertiary pt-4">
           <div className="border-t border-bambu-dark-tertiary pt-4">
@@ -1031,51 +1160,53 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
             )}
             )}
           </div>
           </div>
 
 
-          {/* Schedule */}
-          <div className="border-t border-bambu-dark-tertiary pt-4">
-            <div className="flex items-center justify-between mb-3">
-              <div className="flex items-center gap-2">
-                <Clock className="w-4 h-4 text-bambu-green" />
-                <span className="text-white font-medium">Daily Schedule</span>
+          {/* Schedule - not shown for MQTT plugs (monitor-only) */}
+          {plugType !== 'mqtt' && (
+            <div className="border-t border-bambu-dark-tertiary pt-4">
+              <div className="flex items-center justify-between mb-3">
+                <div className="flex items-center gap-2">
+                  <Clock className="w-4 h-4 text-bambu-green" />
+                  <span className="text-white font-medium">Daily Schedule</span>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={scheduleEnabled}
+                    onChange={(e) => setScheduleEnabled(e.target.checked)}
+                    className="sr-only peer"
+                  />
+                  <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
               </div>
               </div>
-              <label className="relative inline-flex items-center cursor-pointer">
-                <input
-                  type="checkbox"
-                  checked={scheduleEnabled}
-                  onChange={(e) => setScheduleEnabled(e.target.checked)}
-                  className="sr-only peer"
-                />
-                <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
-              </label>
-            </div>
-            {scheduleEnabled && (
-              <div className="space-y-3">
-                <div className="grid grid-cols-2 gap-3">
-                  <div>
-                    <label className="block text-sm text-bambu-gray mb-1">Turn On at</label>
-                    <input
-                      type="time"
-                      value={scheduleOnTime}
-                      onChange={(e) => setScheduleOnTime(e.target.value)}
-                      className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-                    />
-                  </div>
-                  <div>
-                    <label className="block text-sm text-bambu-gray mb-1">Turn Off at</label>
-                    <input
-                      type="time"
-                      value={scheduleOffTime}
-                      onChange={(e) => setScheduleOffTime(e.target.value)}
-                      className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-                    />
+              {scheduleEnabled && (
+                <div className="space-y-3">
+                  <div className="grid grid-cols-2 gap-3">
+                    <div>
+                      <label className="block text-sm text-bambu-gray mb-1">Turn On at</label>
+                      <input
+                        type="time"
+                        value={scheduleOnTime}
+                        onChange={(e) => setScheduleOnTime(e.target.value)}
+                        className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                      />
+                    </div>
+                    <div>
+                      <label className="block text-sm text-bambu-gray mb-1">Turn Off at</label>
+                      <input
+                        type="time"
+                        value={scheduleOffTime}
+                        onChange={(e) => setScheduleOffTime(e.target.value)}
+                        className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                      />
+                    </div>
                   </div>
                   </div>
+                  <p className="text-xs text-bambu-gray">
+                    Automatically turn the plug on/off at these times daily. Leave empty to skip that action.
+                  </p>
                 </div>
                 </div>
-                <p className="text-xs text-bambu-gray">
-                  Automatically turn the plug on/off at these times daily. Leave empty to skip that action.
-                </p>
-              </div>
-            )}
-          </div>
+              )}
+            </div>
+          )}
 
 
           {/* Switchbar Visibility */}
           {/* Switchbar Visibility */}
           <div className="border-t border-bambu-dark-tertiary pt-4">
           <div className="border-t border-bambu-dark-tertiary pt-4">

+ 128 - 73
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 } 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';
@@ -92,7 +92,9 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
   });
   });
 
 
   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;
 
 
   // Generate admin URL with auto-login credentials (Tasmota only)
   // Generate admin URL with auto-login credentials (Tasmota only)
@@ -113,27 +115,49 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
       <Card className="relative">
       <Card className="relative">
         <CardContent className="p-4">
         <CardContent className="p-4">
           {/* Header Row */}
           {/* Header Row */}
-          <div className="flex items-start justify-between mb-3">
-            <div className="flex items-center gap-3">
-              <div className={`p-2 rounded-lg ${isReachable ? (isOn ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20'}`}>
-                {plug.plug_type === 'homeassistant' ? (
+          <div className="flex items-start justify-between gap-2 mb-3">
+            <div className="flex items-center gap-3 min-w-0 flex-1">
+              <div className={`p-2 rounded-lg flex-shrink-0 ${
+                plug.plug_type === 'mqtt'
+                  ? (isReachable ? 'bg-teal-500/20' : 'bg-red-500/20')
+                  : (isReachable ? (isOn ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20')
+              }`}>
+                {plug.plug_type === 'mqtt' ? (
+                  <Radio className={`w-5 h-5 ${isReachable ? 'text-teal-400' : 'text-red-400'}`} />
+                ) : plug.plug_type === 'homeassistant' ? (
                   <Home className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
                   <Home className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
                 ) : (
                 ) : (
                   <Plug className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
                   <Plug className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
                 )}
                 )}
               </div>
               </div>
-              <div>
-                <h3 className="font-medium text-white">{plug.name}</h3>
-                <p className="text-sm text-bambu-gray">
-                  {plug.plug_type === 'homeassistant' ? plug.ha_entity_id : plug.ip_address}
+              <div className="min-w-0">
+                <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>
 
 
             {/* Status indicator */}
             {/* Status indicator */}
-            <div className="flex flex-col items-end gap-1">
+            <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" />
+              ) : plug.plug_type === 'mqtt' ? (
+                /* MQTT plugs - show badge and checkmark when receiving data */
+                <div className="flex items-center gap-1.5 text-sm whitespace-nowrap">
+                  <span className="px-1.5 py-0.5 bg-teal-500/20 text-teal-400 text-[10px] font-medium rounded flex-shrink-0">MQTT</span>
+                  {isReachable && <span className="text-status-ok">✓</span>}
+                </div>
+              ) : plug.plug_type === 'homeassistant' ? (
+                <div className="flex items-center gap-1 text-sm">
+                  <span className="px-1 py-0.5 bg-blue-500/20 text-blue-400 text-[10px] font-medium rounded">HA</span>
+                  <span className={isReachable ? (isOn ? 'text-status-ok' : 'text-bambu-gray') : 'text-status-error'}>
+                    {isReachable ? (status?.state || '?') : 'Offline'}
+                  </span>
+                </div>
               ) : isReachable ? (
               ) : 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" />
@@ -170,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" />
@@ -191,29 +221,49 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
             </div>
             </div>
           )}
           )}
 
 
-          {/* Quick Controls */}
-          <div className="flex gap-2 mb-3">
-            <Button
-              size="sm"
-              variant={isOn ? 'primary' : 'secondary'}
-              disabled={!isReachable || isPending}
-              onClick={() => setShowPowerOnConfirm(true)}
-              className="flex-1"
-            >
-              {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
-              On
-            </Button>
-            <Button
-              size="sm"
-              variant={!isOn ? 'primary' : 'secondary'}
-              disabled={!isReachable || isPending}
-              onClick={() => setShowPowerOffConfirm(true)}
-              className="flex-1"
-            >
-              {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
-              Off
-            </Button>
-          </div>
+          {/* Quick Controls - hidden for MQTT plugs (monitor-only) */}
+          {plug.plug_type !== 'mqtt' && (
+            <div className="flex gap-2 mb-3">
+              <Button
+                size="sm"
+                variant={isOn ? 'primary' : 'secondary'}
+                disabled={!isReachable || isPending}
+                onClick={() => setShowPowerOnConfirm(true)}
+                className="flex-1"
+              >
+                {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
+                On
+              </Button>
+              <Button
+                size="sm"
+                variant={!isOn ? 'primary' : 'secondary'}
+                disabled={!isReachable || isPending}
+                onClick={() => setShowPowerOffConfirm(true)}
+                className="flex-1"
+              >
+                {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
+                Off
+              </Button>
+            </div>
+          )}
+
+          {/* Energy display for MQTT plugs */}
+          {plug.plug_type === 'mqtt' && status?.energy && (
+            <div className="flex gap-2 mb-3 px-3 py-2 bg-bambu-dark rounded-lg">
+              {status.energy.power !== null && status.energy.power !== undefined && (
+                <div className="flex-1 text-center">
+                  <p className="text-lg font-semibold text-white">{Math.round(status.energy.power)}W</p>
+                  <p className="text-xs text-bambu-gray">Power</p>
+                </div>
+              )}
+              {status.energy.today !== null && status.energy.today !== undefined && (
+                <div className="flex-1 text-center border-l border-bambu-dark-tertiary">
+                  <p className="text-lg font-semibold text-white">{status.energy.today.toFixed(2)}</p>
+                  <p className="text-xs text-bambu-gray">kWh Today</p>
+                </div>
+              )}
+            </div>
+          )}
 
 
           {/* Toggle Settings Panel */}
           {/* Toggle Settings Panel */}
           <button
           <button
@@ -222,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>
@@ -250,45 +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 */}
-              <div className="flex items-center justify-between">
-                <div>
-                  <p className="text-sm text-white">Auto On</p>
-                  <p className="text-xs text-bambu-gray">Turn on when print starts</p>
-                </div>
-                <label className="relative inline-flex items-center cursor-pointer">
-                  <input
-                    type="checkbox"
-                    checked={plug.auto_on}
-                    onChange={(e) => updateMutation.mutate({ auto_on: e.target.checked })}
-                    className="sr-only peer"
-                  />
-                  <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
-                </label>
-              </div>
+                  {/* Auto On */}
+                  <div className="flex items-center justify-between">
+                    <div>
+                      <p className="text-sm text-white">Auto On</p>
+                      <p className="text-xs text-bambu-gray">Turn on when print starts</p>
+                    </div>
+                    <label className="relative inline-flex items-center cursor-pointer">
+                      <input
+                        type="checkbox"
+                        checked={plug.auto_on}
+                        onChange={(e) => updateMutation.mutate({ auto_on: e.target.checked })}
+                        className="sr-only peer"
+                      />
+                      <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
+                    </label>
+                  </div>
 
 
-              {/* Auto Off */}
-              <div className="flex items-center justify-between">
-                <div>
-                  <p className="text-sm text-white">Auto Off</p>
-                  <p className="text-xs text-bambu-gray">Turn off when print completes (one-shot)</p>
+                  {/* Auto Off */}
+                  <div className="flex items-center justify-between">
+                    <div>
+                      <p className="text-sm text-white">Auto Off</p>
+                      <p className="text-xs text-bambu-gray">Turn off when print completes (one-shot)</p>
                 </div>
                 </div>
                 <label className="relative inline-flex items-center cursor-pointer">
                 <label className="relative inline-flex items-center cursor-pointer">
                   <input
                   <input
@@ -360,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">

+ 60 - 30
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 } 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,8 +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;
+  const isMqtt = plug.plug_type === 'mqtt';
 
 
   const handleConfirm = () => {
   const handleConfirm = () => {
     if (confirmAction) {
     if (confirmAction) {
@@ -43,14 +46,38 @@ function SwitchItem({ plug }: { plug: SmartPlug }) {
     <>
     <>
       <div className="flex items-center justify-between py-2 px-3 hover:bg-bambu-dark-tertiary rounded-lg transition-colors">
       <div className="flex items-center 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 ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20'}`}>
-            <Plug className={`w-4 h-4 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
+          <div className={`p-1.5 rounded ${
+            isMqtt
+              ? (isReachable ? 'bg-teal-500/20' : 'bg-red-500/20')
+              : (isReachable ? (isOn ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20')
+          }`}>
+            {isMqtt ? (
+              <Radio className={`w-4 h-4 ${isReachable ? 'text-teal-400' : 'text-red-400'}`} />
+            ) : (
+              <Plug className={`w-4 h-4 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
+            )}
           </div>
           </div>
           <div>
           <div>
             <p className="text-sm text-white font-medium">{plug.name}</p>
             <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" />
+              ) : 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" />
@@ -73,32 +100,35 @@ function SwitchItem({ plug }: { plug: SmartPlug }) {
           </div>
           </div>
         </div>
         </div>
 
 
-        <div className="flex gap-1">
-          <button
-            onClick={() => setConfirmAction('on')}
-            disabled={!isReachable || isPending}
-            className={`p-1.5 rounded transition-colors ${
-              isOn
-                ? 'bg-bambu-green text-white'
-                : 'bg-bambu-dark text-bambu-gray hover:text-white'
-            } disabled:opacity-50 disabled:cursor-not-allowed`}
-            title="Turn On"
-          >
-            {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
-          </button>
-          <button
-            onClick={() => setConfirmAction('off')}
-            disabled={!isReachable || isPending}
-            className={`p-1.5 rounded transition-colors ${
-              !isOn && isReachable
-                ? 'bg-bambu-dark-tertiary text-white'
-                : 'bg-bambu-dark text-bambu-gray hover:text-white'
-            } disabled:opacity-50 disabled:cursor-not-allowed`}
-            title="Turn Off"
-          >
-            {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
-          </button>
-        </div>
+        {/* Hide on/off buttons for MQTT plugs (monitor-only) */}
+        {!isMqtt && (
+          <div className="flex gap-1">
+            <button
+              onClick={() => setConfirmAction('on')}
+              disabled={!isReachable || isPending}
+              className={`p-1.5 rounded transition-colors ${
+                isOn
+                  ? 'bg-bambu-green text-white'
+                  : 'bg-bambu-dark text-bambu-gray hover:text-white'
+              } disabled:opacity-50 disabled:cursor-not-allowed`}
+              title="Turn On"
+            >
+              {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
+            </button>
+            <button
+              onClick={() => setConfirmAction('off')}
+              disabled={!isReachable || isPending}
+              className={`p-1.5 rounded transition-colors ${
+                !isOn && isReachable
+                  ? 'bg-bambu-dark-tertiary text-white'
+                  : 'bg-bambu-dark text-bambu-gray hover:text-white'
+              } disabled:opacity-50 disabled:cursor-not-allowed`}
+              title="Turn Off"
+            >
+              {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
+            </button>
+          </div>
+        )}
       </div>
       </div>
 
 
       {confirmAction && (
       {confirmAction && (

+ 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;
         }
         }
       }
       }
 
 

Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
static/assets/index-CmjSB2jQ.js


Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
static/assets/index-Cs7zD_Fu.css


Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
static/assets/index-DFuUL8IF.css


+ 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-CKmo1TxA.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DFuUL8IF.css">
+    <script type="module" crossorigin src="/assets/index-CmjSB2jQ.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-Cs7zD_Fu.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.