Browse Source

Added support for Tasmota based smart power plugs and automation

Martin Ziegler 6 months ago
parent
commit
5c27da41c5

+ 66 - 0
README.md

@@ -25,6 +25,11 @@
 - **Automatic Print Archiving** - Automatically saves 3MF files when prints complete
 - **3D Model Preview** - Interactive Three.js viewer for archived prints
 - **Real-time Monitoring** - Live printer status via WebSocket with print progress, temperatures, and more
+- **Smart Plug Integration** - Control Tasmota-based smart plugs with automation:
+  - Auto power-on when print starts
+  - Auto power-off when print completes
+  - Time-based delay (1-60 minutes)
+  - Temperature-based delay (waits for nozzle to cool down)
 - **Print Statistics Dashboard** - Customizable dashboard with drag-and-drop widgets
   - Print success rates
   - Filament usage trends
@@ -319,6 +324,52 @@ Prints are automatically archived when they complete. You can also:
 4. Click "Edit" to modify the project page metadata
 5. Changes are saved directly to the 3MF file
 
+### Smart Plug Integration
+
+Bambusy supports Tasmota-based smart plugs for automated power control. This is useful for:
+- Automatically turning on your printer when a print starts
+- Safely turning off the printer after it cools down
+- Energy savings by powering off idle printers
+
+#### Supported Devices
+
+Any smart plug running [Tasmota](https://tasmota.github.io/docs/) firmware with HTTP API enabled. Popular compatible devices include:
+- Sonoff S31 / S26
+- Gosund / Teckin / Treatlife smart plugs
+- Any ESP8266/ESP32-based plug with Tasmota
+
+#### Setting Up a Smart Plug
+
+1. Go to **Settings** > **Smart Plugs**
+2. Click **Add Plug**
+3. Enter the plug's IP address and click **Test** to verify connection
+4. Give it a name (auto-filled from device if available)
+5. Optionally add username/password if your Tasmota requires authentication
+6. Link it to a printer for automation
+7. Click **Add**
+
+#### Automation Options
+
+Once linked to a printer, you can configure:
+
+| Setting | Description |
+|---------|-------------|
+| **Enabled** | Master toggle for all automation |
+| **Auto On** | Turn on plug when a print starts |
+| **Auto Off** | Turn off plug when print completes |
+| **Delay Mode** | Choose how to delay the power-off |
+
+**Delay Modes:**
+- **Time-based**: Wait a fixed number of minutes (1-60) after print completes
+- **Temperature-based**: Wait until nozzle temperature drops below threshold (default 70°C)
+
+#### Manual Control
+
+Each plug card shows:
+- Current status (ON/OFF/Offline)
+- On/Off buttons for manual control
+- Expandable settings panel
+
 ## Tech Stack
 
 - **Backend**: Python / FastAPI
@@ -408,6 +459,20 @@ uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 --log-level debug
 sudo journalctl -u bambusy -f
 ```
 
+### Smart plug not responding
+
+1. **Check the IP address** - Make sure the plug is on the same network and the IP hasn't changed
+2. **Test via browser** - Visit `http://<plug-ip>/cm?cmnd=Power` to test directly
+3. **Check Tasmota web interface** - Access `http://<plug-ip>` to verify Tasmota is running
+4. **Authentication** - If Tasmota has a password set, configure it in the plug settings
+5. **Firewall** - Ensure port 80 is accessible between Bambusy server and the plug
+
+### Auto power-off not working
+
+1. **Check plug is linked** - The plug must be linked to a printer for automation
+2. **Verify automation is enabled** - Check the Enabled, Auto On, and Auto Off toggles
+3. **Temperature mode issues** - If using temperature mode, ensure the printer is still connected so Bambusy can read the nozzle temperature
+
 ## Known Issues / Roadmap
 
 ### Beta Limitations
@@ -422,6 +487,7 @@ sudo journalctl -u bambusy -f
 - [ ] Timelapse video integration
 - [ ] Mobile-responsive improvements
 - [ ] Printer groups/organization
+- [x] Smart plug integration (Tasmota)
 
 ## License
 

+ 211 - 0
backend/app/api/routes/smart_plugs.py

@@ -0,0 +1,211 @@
+"""API routes for smart plug management."""
+
+import logging
+from datetime import datetime
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+
+from backend.app.core.database import get_db
+from backend.app.models.smart_plug import SmartPlug
+from backend.app.models.printer import Printer
+from backend.app.schemas.smart_plug import (
+    SmartPlugCreate,
+    SmartPlugUpdate,
+    SmartPlugResponse,
+    SmartPlugControl,
+    SmartPlugStatus,
+    SmartPlugTestConnection,
+)
+from backend.app.services.tasmota import tasmota_service
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/smart-plugs", tags=["smart-plugs"])
+
+
+@router.get("/", response_model=list[SmartPlugResponse])
+async def list_smart_plugs(db: AsyncSession = Depends(get_db)):
+    """List all smart plugs."""
+    result = await db.execute(select(SmartPlug).order_by(SmartPlug.name))
+    return list(result.scalars().all())
+
+
+@router.post("/", response_model=SmartPlugResponse)
+async def create_smart_plug(
+    data: SmartPlugCreate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Create a new smart plug."""
+    # Validate printer_id if provided
+    if data.printer_id:
+        result = await db.execute(
+            select(Printer).where(Printer.id == data.printer_id)
+        )
+        if not result.scalar_one_or_none():
+            raise HTTPException(400, "Printer not found")
+
+        # Check if printer already has a plug assigned
+        result = await db.execute(
+            select(SmartPlug).where(SmartPlug.printer_id == data.printer_id)
+        )
+        if result.scalar_one_or_none():
+            raise HTTPException(400, "This printer already has a smart plug assigned")
+
+    plug = SmartPlug(**data.model_dump())
+    db.add(plug)
+    await db.commit()
+    await db.refresh(plug)
+
+    logger.info(f"Created smart plug '{plug.name}' at {plug.ip_address}")
+    return plug
+
+
+@router.get("/{plug_id}", response_model=SmartPlugResponse)
+async def get_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)):
+    """Get a specific smart plug."""
+    result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
+    plug = result.scalar_one_or_none()
+    if not plug:
+        raise HTTPException(404, "Smart plug not found")
+    return plug
+
+
+@router.patch("/{plug_id}", response_model=SmartPlugResponse)
+async def update_smart_plug(
+    plug_id: int,
+    data: SmartPlugUpdate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Update a smart plug."""
+    result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
+    plug = result.scalar_one_or_none()
+    if not plug:
+        raise HTTPException(404, "Smart plug not found")
+
+    update_data = data.model_dump(exclude_unset=True)
+
+    # Validate new printer_id if being changed
+    if "printer_id" in update_data and update_data["printer_id"]:
+        new_printer_id = update_data["printer_id"]
+
+        # Check printer exists
+        result = await db.execute(
+            select(Printer).where(Printer.id == new_printer_id)
+        )
+        if not result.scalar_one_or_none():
+            raise HTTPException(400, "Printer not found")
+
+        # Check if that printer already has a different plug assigned
+        result = await db.execute(
+            select(SmartPlug).where(
+                SmartPlug.printer_id == new_printer_id,
+                SmartPlug.id != plug_id,
+            )
+        )
+        if result.scalar_one_or_none():
+            raise HTTPException(400, "This printer already has a smart plug assigned")
+
+    for field, value in update_data.items():
+        setattr(plug, field, value)
+
+    await db.commit()
+    await db.refresh(plug)
+
+    logger.info(f"Updated smart plug '{plug.name}'")
+    return plug
+
+
+@router.delete("/{plug_id}")
+async def delete_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)):
+    """Delete a smart plug."""
+    result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
+    plug = result.scalar_one_or_none()
+    if not plug:
+        raise HTTPException(404, "Smart plug not found")
+
+    plug_name = plug.name
+    await db.delete(plug)
+    await db.commit()
+
+    logger.info(f"Deleted smart plug '{plug_name}'")
+    return {"message": "Smart plug deleted"}
+
+
+@router.post("/{plug_id}/control")
+async def control_smart_plug(
+    plug_id: int,
+    control: SmartPlugControl,
+    db: AsyncSession = Depends(get_db),
+):
+    """Manual control: on/off/toggle."""
+    result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
+    plug = result.scalar_one_or_none()
+    if not plug:
+        raise HTTPException(404, "Smart plug not found")
+
+    if control.action == "on":
+        success = await tasmota_service.turn_on(plug)
+        expected_state = "ON"
+    elif control.action == "off":
+        success = await tasmota_service.turn_off(plug)
+        expected_state = "OFF"
+    elif control.action == "toggle":
+        success = await tasmota_service.toggle(plug)
+        expected_state = None  # Unknown after toggle
+    else:
+        raise HTTPException(400, f"Invalid action: {control.action}")
+
+    if not success:
+        raise HTTPException(503, "Failed to communicate with device")
+
+    # Update last state
+    if expected_state:
+        plug.last_state = expected_state
+    plug.last_checked = datetime.utcnow()
+    await db.commit()
+
+    return {"success": True, "action": control.action}
+
+
+@router.get("/{plug_id}/status", response_model=SmartPlugStatus)
+async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)):
+    """Get current plug status from device."""
+    result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
+    plug = result.scalar_one_or_none()
+    if not plug:
+        raise HTTPException(404, "Smart plug not found")
+
+    status = await tasmota_service.get_status(plug)
+
+    # Update last state in database
+    if status["reachable"]:
+        plug.last_state = status["state"]
+        plug.last_checked = datetime.utcnow()
+        await db.commit()
+
+    return SmartPlugStatus(
+        state=status["state"],
+        reachable=status["reachable"],
+        device_name=status.get("device_name"),
+    )
+
+
+@router.post("/test-connection")
+async def test_connection(data: SmartPlugTestConnection):
+    """Test connection to a Tasmota device."""
+    result = await tasmota_service.test_connection(
+        data.ip_address,
+        data.username,
+        data.password,
+    )
+
+    if not result["success"]:
+        raise HTTPException(503, result.get("error", "Failed to connect to device"))
+
+    return {
+        "success": True,
+        "state": result["state"],
+        "device_name": result.get("device_name"),
+    }

+ 20 - 1
backend/app/main.py

@@ -10,7 +10,7 @@ from fastapi.responses import FileResponse
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import init_db, async_session
 from backend.app.core.websocket import ws_manager
-from backend.app.api.routes import printers, archives, websocket, filaments, cloud
+from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs
 from backend.app.api.routes import settings as settings_routes
 from backend.app.services.printer_manager import (
     printer_manager,
@@ -20,6 +20,7 @@ from backend.app.services.printer_manager import (
 from backend.app.services.bambu_mqtt import PrinterState
 from backend.app.services.archive import ArchiveService
 from backend.app.services.bambu_ftp import download_file_async
+from backend.app.services.smart_plug_manager import smart_plug_manager
 
 
 # Track active prints: {(printer_id, filename): archive_id}
@@ -177,6 +178,14 @@ async def on_print_start(printer_id: int, data: dict):
             if temp_path and temp_path.exists():
                 temp_path.unlink()
 
+    # Smart plug automation: turn on plug when print starts
+    try:
+        async with async_session() as db:
+            await smart_plug_manager.on_print_start(printer_id, db)
+    except Exception as e:
+        import logging
+        logging.getLogger(__name__).warning(f"Smart plug on_print_start failed: {e}")
+
 
 async def on_print_complete(printer_id: int, data: dict):
     """Handle print completion - update the archive status."""
@@ -251,6 +260,15 @@ async def on_print_complete(printer_id: int, data: dict):
             "status": status,
         })
 
+    # Smart plug automation: schedule turn off when print completes
+    try:
+        async with async_session() as db:
+            status = data.get("status", "completed")
+            await smart_plug_manager.on_print_complete(printer_id, status, db)
+    except Exception as e:
+        import logging
+        logging.getLogger(__name__).warning(f"Smart plug on_print_complete failed: {e}")
+
 
 @asynccontextmanager
 async def lifespan(app: FastAPI):
@@ -287,6 +305,7 @@ app.include_router(archives.router, prefix=app_settings.api_prefix)
 app.include_router(filaments.router, prefix=app_settings.api_prefix)
 app.include_router(settings_routes.router, prefix=app_settings.api_prefix)
 app.include_router(cloud.router, prefix=app_settings.api_prefix)
+app.include_router(smart_plugs.router, prefix=app_settings.api_prefix)
 app.include_router(websocket.router, prefix=app_settings.api_prefix)
 
 

+ 7 - 0
backend/app/models/__init__.py

@@ -0,0 +1,7 @@
+from backend.app.models.printer import Printer
+from backend.app.models.archive import PrintArchive
+from backend.app.models.filament import Filament
+from backend.app.models.settings import Settings
+from backend.app.models.smart_plug import SmartPlug
+
+__all__ = ["Printer", "PrintArchive", "Filament", "Settings", "SmartPlug"]

+ 4 - 0
backend/app/models/printer.py

@@ -27,6 +27,10 @@ class Printer(Base):
     archives: Mapped[list["PrintArchive"]] = relationship(
         back_populates="printer", cascade="all, delete-orphan"
     )
+    smart_plug: Mapped["SmartPlug | None"] = relationship(
+        back_populates="printer", uselist=False
+    )
 
 
 from backend.app.models.archive import PrintArchive  # noqa: E402
+from backend.app.models.smart_plug import SmartPlug  # noqa: E402

+ 50 - 0
backend/app/models/smart_plug.py

@@ -0,0 +1,50 @@
+from datetime import datetime
+from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+
+class SmartPlug(Base):
+    """Tasmota smart plug for printer power control."""
+
+    __tablename__ = "smart_plugs"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(100))
+    ip_address: Mapped[str] = mapped_column(String(45))  # IPv4/IPv6
+
+    # Link to printer (1:1)
+    printer_id: Mapped[int | None] = mapped_column(
+        ForeignKey("printers.id", ondelete="SET NULL"), unique=True, nullable=True
+    )
+
+    # Automation settings
+    enabled: Mapped[bool] = mapped_column(Boolean, default=True)
+    auto_on: Mapped[bool] = mapped_column(Boolean, default=True)  # Turn on at print start
+    auto_off: Mapped[bool] = mapped_column(Boolean, default=True)  # Turn off at print complete/fail
+
+    # Turn-off delay mode: "time" or "temperature"
+    off_delay_mode: Mapped[str] = mapped_column(String(20), default="time")
+    off_delay_minutes: Mapped[int] = mapped_column(Integer, default=5)  # For time mode
+    off_temp_threshold: Mapped[int] = mapped_column(Integer, default=70)  # For temp mode (°C)
+
+    # Optional auth (some Tasmota configs require it)
+    username: Mapped[str | None] = mapped_column(String(50), nullable=True)
+    password: Mapped[str | None] = mapped_column(String(100), nullable=True)
+
+    # Status tracking
+    last_state: Mapped[str | None] = mapped_column(String(10), nullable=True)  # "ON"/"OFF"
+    last_checked: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+
+    # Timestamps
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(
+        DateTime, server_default=func.now(), onupdate=func.now()
+    )
+
+    # Relationship
+    printer: Mapped["Printer"] = relationship(back_populates="smart_plug")
+
+
+from backend.app.models.printer import Printer  # noqa: E402

+ 43 - 0
backend/app/schemas/__init__.py

@@ -0,0 +1,43 @@
+from backend.app.schemas.printer import (
+    PrinterBase,
+    PrinterCreate,
+    PrinterUpdate,
+    PrinterResponse,
+    PrinterStatus,
+)
+from backend.app.schemas.archive import (
+    ArchiveBase,
+    ArchiveUpdate,
+    ArchiveResponse,
+    ProjectPageResponse,
+    ProjectPageImage,
+)
+from backend.app.schemas.smart_plug import (
+    SmartPlugBase,
+    SmartPlugCreate,
+    SmartPlugUpdate,
+    SmartPlugResponse,
+    SmartPlugControl,
+    SmartPlugStatus,
+    SmartPlugTestConnection,
+)
+
+__all__ = [
+    "PrinterBase",
+    "PrinterCreate",
+    "PrinterUpdate",
+    "PrinterResponse",
+    "PrinterStatus",
+    "ArchiveBase",
+    "ArchiveUpdate",
+    "ArchiveResponse",
+    "ProjectPageResponse",
+    "ProjectPageImage",
+    "SmartPlugBase",
+    "SmartPlugCreate",
+    "SmartPlugUpdate",
+    "SmartPlugResponse",
+    "SmartPlugControl",
+    "SmartPlugStatus",
+    "SmartPlugTestConnection",
+]

+ 62 - 0
backend/app/schemas/smart_plug.py

@@ -0,0 +1,62 @@
+from datetime import datetime
+from typing import Literal
+from pydantic import BaseModel, Field
+
+
+class SmartPlugBase(BaseModel):
+    name: str = Field(..., min_length=1, max_length=100)
+    ip_address: str = Field(..., pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
+    printer_id: int | None = None
+    enabled: bool = True
+    auto_on: bool = True
+    auto_off: bool = True
+    off_delay_mode: Literal["time", "temperature"] = "time"
+    off_delay_minutes: int = Field(default=5, ge=0, le=60)
+    off_temp_threshold: int = Field(default=70, ge=30, le=150)
+    username: str | None = None
+    password: str | None = None
+
+
+class SmartPlugCreate(SmartPlugBase):
+    pass
+
+
+class SmartPlugUpdate(BaseModel):
+    name: str | None = None
+    ip_address: str | None = None
+    printer_id: int | None = None
+    enabled: bool | None = None
+    auto_on: bool | None = None
+    auto_off: bool | None = None
+    off_delay_mode: Literal["time", "temperature"] | None = None
+    off_delay_minutes: int | None = Field(default=None, ge=0, le=60)
+    off_temp_threshold: int | None = Field(default=None, ge=30, le=150)
+    username: str | None = None
+    password: str | None = None
+
+
+class SmartPlugResponse(SmartPlugBase):
+    id: int
+    last_state: str | None = None
+    last_checked: datetime | None = None
+    created_at: datetime
+    updated_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
+class SmartPlugControl(BaseModel):
+    action: Literal["on", "off", "toggle"]
+
+
+class SmartPlugStatus(BaseModel):
+    state: str | None = None  # "ON", "OFF", or None if unreachable
+    reachable: bool = True
+    device_name: str | None = None
+
+
+class SmartPlugTestConnection(BaseModel):
+    ip_address: str = Field(..., pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
+    username: str | None = None
+    password: str | None = None

+ 5 - 0
backend/app/services/bambu_mqtt.py

@@ -121,6 +121,11 @@ class BambuMQTTClient:
             temps["nozzle"] = float(data["nozzle_temper"])
         if "nozzle_target_temper" in data:
             temps["nozzle_target"] = float(data["nozzle_target_temper"])
+        # Second nozzle for dual-extruder printers (H2 series)
+        if "nozzle_temper_2" in data:
+            temps["nozzle_2"] = float(data["nozzle_temper_2"])
+        if "nozzle_target_temper_2" in data:
+            temps["nozzle_2_target"] = float(data["nozzle_target_temper_2"])
         if "chamber_temper" in data:
             temps["chamber"] = float(data["chamber_temper"])
         if temps:

+ 250 - 0
backend/app/services/smart_plug_manager.py

@@ -0,0 +1,250 @@
+"""Manager for smart plug automation and delayed turn-off."""
+
+import asyncio
+import logging
+from datetime import datetime
+from typing import TYPE_CHECKING
+
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+
+from backend.app.services.tasmota import tasmota_service
+from backend.app.services.printer_manager import printer_manager
+
+if TYPE_CHECKING:
+    from backend.app.models.smart_plug import SmartPlug
+
+logger = logging.getLogger(__name__)
+
+
+class SmartPlugManager:
+    """Manages smart plug automation and delayed turn-off."""
+
+    def __init__(self):
+        self._pending_off: dict[int, asyncio.Task] = {}  # plug_id -> task
+        self._loop: asyncio.AbstractEventLoop | None = None
+
+    def set_event_loop(self, loop: asyncio.AbstractEventLoop):
+        """Set the event loop for async operations."""
+        self._loop = loop
+
+    async def _get_plug_for_printer(
+        self, printer_id: int, db: AsyncSession
+    ) -> "SmartPlug | None":
+        """Get the smart plug linked to a printer."""
+        from backend.app.models.smart_plug import SmartPlug
+
+        result = await db.execute(
+            select(SmartPlug).where(SmartPlug.printer_id == printer_id)
+        )
+        return result.scalar_one_or_none()
+
+    async def on_print_start(self, printer_id: int, db: AsyncSession):
+        """Called when a print starts - turn on plug if configured."""
+        plug = await self._get_plug_for_printer(printer_id, db)
+
+        if not plug:
+            return
+
+        if not plug.enabled:
+            logger.debug(f"Smart plug '{plug.name}' is disabled, skipping auto-on")
+            return
+
+        if not plug.auto_on:
+            logger.debug(f"Smart plug '{plug.name}' auto_on is disabled")
+            return
+
+        # Cancel any pending off task
+        self._cancel_pending_off(plug.id)
+
+        # Turn on the plug
+        logger.info(f"Print started on printer {printer_id}, turning on plug '{plug.name}'")
+        success = await tasmota_service.turn_on(plug)
+
+        if success:
+            # Update last state
+            plug.last_state = "ON"
+            plug.last_checked = datetime.utcnow()
+            await db.commit()
+
+    async def on_print_complete(
+        self, printer_id: int, status: str, db: AsyncSession
+    ):
+        """Called when a print completes - schedule turn off if configured."""
+        plug = await self._get_plug_for_printer(printer_id, db)
+
+        if not plug:
+            return
+
+        if not plug.enabled:
+            logger.debug(f"Smart plug '{plug.name}' is disabled, skipping auto-off")
+            return
+
+        if not plug.auto_off:
+            logger.debug(f"Smart plug '{plug.name}' auto_off is disabled")
+            return
+
+        logger.info(
+            f"Print completed on printer {printer_id} (status: {status}), "
+            f"scheduling turn-off for plug '{plug.name}'"
+        )
+
+        if plug.off_delay_mode == "time":
+            self._schedule_delayed_off(plug, plug.off_delay_minutes * 60)
+        elif plug.off_delay_mode == "temperature":
+            self._schedule_temp_based_off(plug, printer_id, plug.off_temp_threshold)
+
+    def _schedule_delayed_off(self, plug: "SmartPlug", delay_seconds: int):
+        """Schedule turn-off after delay."""
+        # Cancel any existing task for this plug
+        self._cancel_pending_off(plug.id)
+
+        logger.info(
+            f"Scheduling turn-off for plug '{plug.name}' in {delay_seconds} seconds"
+        )
+
+        task = asyncio.create_task(
+            self._delayed_off(plug.id, plug.ip_address, plug.username, plug.password, delay_seconds)
+        )
+        self._pending_off[plug.id] = task
+
+    async def _delayed_off(
+        self,
+        plug_id: int,
+        ip_address: str,
+        username: str | None,
+        password: str | None,
+        delay_seconds: int,
+    ):
+        """Wait and turn off."""
+        try:
+            await asyncio.sleep(delay_seconds)
+
+            # Create a minimal plug-like object for the tasmota service
+            class PlugInfo:
+                def __init__(self):
+                    self.ip_address = ip_address
+                    self.username = username
+                    self.password = password
+                    self.name = f"plug_{plug_id}"
+
+            plug_info = PlugInfo()
+            await tasmota_service.turn_off(plug_info)
+            logger.info(f"Turned off plug {plug_id} after time delay")
+
+        except asyncio.CancelledError:
+            logger.debug(f"Delayed turn-off cancelled for plug {plug_id}")
+        finally:
+            self._pending_off.pop(plug_id, None)
+
+    def _schedule_temp_based_off(
+        self, plug: "SmartPlug", printer_id: int, temp_threshold: int
+    ):
+        """Monitor temperature and turn off when below threshold."""
+        # Cancel any existing task for this plug
+        self._cancel_pending_off(plug.id)
+
+        logger.info(
+            f"Scheduling temperature-based turn-off for plug '{plug.name}' "
+            f"(threshold: {temp_threshold}°C)"
+        )
+
+        task = asyncio.create_task(
+            self._temp_based_off(
+                plug.id,
+                plug.ip_address,
+                plug.username,
+                plug.password,
+                printer_id,
+                temp_threshold,
+            )
+        )
+        self._pending_off[plug.id] = task
+
+    async def _temp_based_off(
+        self,
+        plug_id: int,
+        ip_address: str,
+        username: str | None,
+        password: str | None,
+        printer_id: int,
+        temp_threshold: int,
+    ):
+        """Poll temperature until below threshold, then turn off.
+
+        For dual-extruder printers (H2 series), checks both nozzles.
+        """
+        try:
+            check_interval = 10  # seconds
+            max_wait = 3600  # 1 hour max
+            elapsed = 0
+
+            while elapsed < max_wait:
+                status = printer_manager.get_status(printer_id)
+
+                if status:
+                    temps = status.temperatures or {}
+                    nozzle_temp = temps.get("nozzle", 999)
+                    # Check second nozzle for dual-extruder printers (H2 series)
+                    nozzle_2_temp = temps.get("nozzle_2")
+
+                    # Get the maximum temperature across all nozzles
+                    max_nozzle_temp = nozzle_temp
+                    if nozzle_2_temp is not None:
+                        max_nozzle_temp = max(nozzle_temp, nozzle_2_temp)
+                        logger.debug(
+                            f"Checking temp for plug {plug_id}: nozzle1={nozzle_temp}°C, "
+                            f"nozzle2={nozzle_2_temp}°C, max={max_nozzle_temp}°C, "
+                            f"threshold={temp_threshold}°C"
+                        )
+                    else:
+                        logger.debug(
+                            f"Checking temp for plug {plug_id}: nozzle={nozzle_temp}°C, "
+                            f"threshold={temp_threshold}°C"
+                        )
+
+                    if max_nozzle_temp < temp_threshold:
+                        # All nozzles are below threshold, turn off
+                        class PlugInfo:
+                            def __init__(self):
+                                self.ip_address = ip_address
+                                self.username = username
+                                self.password = password
+                                self.name = f"plug_{plug_id}"
+
+                        plug_info = PlugInfo()
+                        await tasmota_service.turn_off(plug_info)
+                        logger.info(
+                            f"Turned off plug {plug_id} after nozzle temp dropped to "
+                            f"{max_nozzle_temp}°C (threshold: {temp_threshold}°C)"
+                        )
+                        break
+
+                await asyncio.sleep(check_interval)
+                elapsed += check_interval
+
+            if elapsed >= max_wait:
+                logger.warning(
+                    f"Temperature-based turn-off timed out for plug {plug_id} after {max_wait}s"
+                )
+
+        except asyncio.CancelledError:
+            logger.debug(f"Temperature-based turn-off cancelled for plug {plug_id}")
+        finally:
+            self._pending_off.pop(plug_id, None)
+
+    def _cancel_pending_off(self, plug_id: int):
+        """Cancel any pending off task for this plug."""
+        if plug_id in self._pending_off:
+            logger.debug(f"Cancelling pending turn-off for plug {plug_id}")
+            self._pending_off[plug_id].cancel()
+            del self._pending_off[plug_id]
+
+    def cancel_all_pending(self):
+        """Cancel all pending turn-off tasks."""
+        for plug_id in list(self._pending_off.keys()):
+            self._cancel_pending_off(plug_id)
+
+
+# Global singleton
+smart_plug_manager = SmartPlugManager()

+ 195 - 0
backend/app/services/tasmota.py

@@ -0,0 +1,195 @@
+"""Service for communicating with Tasmota devices via HTTP API."""
+
+import asyncio
+import logging
+from datetime import datetime
+from typing import TYPE_CHECKING
+
+import httpx
+
+if TYPE_CHECKING:
+    from backend.app.models.smart_plug import SmartPlug
+
+logger = logging.getLogger(__name__)
+
+
+class TasmotaService:
+    """Service for communicating with Tasmota devices via HTTP API."""
+
+    def __init__(self, timeout: float = 5.0):
+        self.timeout = timeout
+
+    def _build_url(
+        self,
+        ip: str,
+        command: str,
+        username: str | None = None,
+        password: str | None = None,
+    ) -> str:
+        """Build Tasmota command URL."""
+        # URL encode the command
+        cmd = command.replace(" ", "%20")
+
+        if username and password:
+            return f"http://{username}:{password}@{ip}/cm?cmnd={cmd}"
+        return f"http://{ip}/cm?cmnd={cmd}"
+
+    async def _send_command(
+        self,
+        ip: str,
+        command: str,
+        username: str | None = None,
+        password: str | None = None,
+    ) -> dict | None:
+        """Send a command to a Tasmota device and return the response."""
+        url = self._build_url(ip, command, username, password)
+
+        try:
+            async with httpx.AsyncClient(timeout=self.timeout) as client:
+                response = await client.get(url)
+                response.raise_for_status()
+                return response.json()
+        except httpx.TimeoutException:
+            logger.warning(f"Tasmota device at {ip} timed out")
+            return None
+        except httpx.HTTPStatusError as e:
+            logger.warning(f"Tasmota device at {ip} returned error: {e}")
+            return None
+        except httpx.RequestError as e:
+            logger.warning(f"Failed to connect to Tasmota device at {ip}: {e}")
+            return None
+        except Exception as e:
+            logger.error(f"Unexpected error communicating with Tasmota at {ip}: {e}")
+            return None
+
+    async def get_status(self, plug: "SmartPlug") -> dict:
+        """Get current power state and device info.
+
+        Returns dict with:
+            - state: "ON" or "OFF" or None if unreachable
+            - reachable: bool
+            - device_name: str or None
+        """
+        result = await self._send_command(
+            plug.ip_address, "Power", plug.username, plug.password
+        )
+
+        if result is None:
+            return {"state": None, "reachable": False, "device_name": None}
+
+        # Response format: {"POWER":"ON"} or {"POWER":"OFF"}
+        # Some devices use {"POWER1":"ON"} for multi-relay
+        state = None
+        for key in ["POWER", "POWER1"]:
+            if key in result:
+                state = result[key]
+                break
+
+        return {"state": state, "reachable": True, "device_name": None}
+
+    async def turn_on(self, plug: "SmartPlug") -> bool:
+        """Turn on the plug. Returns True if successful."""
+        result = await self._send_command(
+            plug.ip_address, "Power On", plug.username, plug.password
+        )
+
+        if result is None:
+            return False
+
+        # Check if the command was successful
+        state = result.get("POWER") or result.get("POWER1")
+        success = state == "ON"
+
+        if success:
+            logger.info(f"Turned ON smart plug '{plug.name}' at {plug.ip_address}")
+        else:
+            logger.warning(
+                f"Failed to turn ON smart plug '{plug.name}' at {plug.ip_address}"
+            )
+
+        return success
+
+    async def turn_off(self, plug: "SmartPlug") -> bool:
+        """Turn off the plug. Returns True if successful."""
+        result = await self._send_command(
+            plug.ip_address, "Power Off", plug.username, plug.password
+        )
+
+        if result is None:
+            return False
+
+        # Check if the command was successful
+        state = result.get("POWER") or result.get("POWER1")
+        success = state == "OFF"
+
+        if success:
+            logger.info(f"Turned OFF smart plug '{plug.name}' at {plug.ip_address}")
+        else:
+            logger.warning(
+                f"Failed to turn OFF smart plug '{plug.name}' at {plug.ip_address}"
+            )
+
+        return success
+
+    async def toggle(self, plug: "SmartPlug") -> bool:
+        """Toggle the plug state. Returns True if successful."""
+        result = await self._send_command(
+            plug.ip_address, "Power Toggle", plug.username, plug.password
+        )
+
+        if result is None:
+            return False
+
+        state = result.get("POWER") or result.get("POWER1")
+        success = state in ["ON", "OFF"]
+
+        if success:
+            logger.info(
+                f"Toggled smart plug '{plug.name}' at {plug.ip_address} to {state}"
+            )
+
+        return success
+
+    async def test_connection(
+        self,
+        ip: str,
+        username: str | None = None,
+        password: str | None = None,
+    ) -> dict:
+        """Test connection to a Tasmota device.
+
+        Returns dict with:
+            - success: bool
+            - state: current power state or None
+            - device_name: device name or None
+            - error: error message if failed
+        """
+        # Try to get power status
+        result = await self._send_command(ip, "Power", username, password)
+
+        if result is None:
+            return {
+                "success": False,
+                "state": None,
+                "device_name": None,
+                "error": "Could not connect to device",
+            }
+
+        state = result.get("POWER") or result.get("POWER1")
+
+        # Try to get device name
+        status_result = await self._send_command(ip, "Status 0", username, password)
+        device_name = None
+        if status_result and "Status" in status_result:
+            device_name = status_result["Status"].get("DeviceName")
+
+        return {
+            "success": True,
+            "state": state,
+            "device_name": device_name,
+            "error": None,
+        }
+
+
+# Singleton instance
+tasmota_service = TasmotaService()

+ 88 - 0
frontend/src/api/client.ts

@@ -161,6 +161,66 @@ export interface CloudDevice {
   online: boolean;
 }
 
+// Smart Plug types
+export interface SmartPlug {
+  id: number;
+  name: string;
+  ip_address: string;
+  printer_id: number | null;
+  enabled: boolean;
+  auto_on: boolean;
+  auto_off: boolean;
+  off_delay_mode: 'time' | 'temperature';
+  off_delay_minutes: number;
+  off_temp_threshold: number;
+  username: string | null;
+  password: string | null;
+  last_state: string | null;
+  last_checked: string | null;
+  created_at: string;
+  updated_at: string;
+}
+
+export interface SmartPlugCreate {
+  name: string;
+  ip_address: string;
+  printer_id?: number | null;
+  enabled?: boolean;
+  auto_on?: boolean;
+  auto_off?: boolean;
+  off_delay_mode?: 'time' | 'temperature';
+  off_delay_minutes?: number;
+  off_temp_threshold?: number;
+  username?: string | null;
+  password?: string | null;
+}
+
+export interface SmartPlugUpdate {
+  name?: string;
+  ip_address?: string;
+  printer_id?: number | null;
+  enabled?: boolean;
+  auto_on?: boolean;
+  auto_off?: boolean;
+  off_delay_mode?: 'time' | 'temperature';
+  off_delay_minutes?: number;
+  off_temp_threshold?: number;
+  username?: string | null;
+  password?: string | null;
+}
+
+export interface SmartPlugStatus {
+  state: string | null;
+  reachable: boolean;
+  device_name: string | null;
+}
+
+export interface SmartPlugTestResult {
+  success: boolean;
+  state: string | null;
+  device_name: string | null;
+}
+
 // API functions
 export const api = {
   // Printers
@@ -399,4 +459,32 @@ export const api = {
   getCloudSettingDetail: (settingId: string) =>
     request<Record<string, unknown>>(`/cloud/settings/${settingId}`),
   getCloudDevices: () => request<CloudDevice[]>('/cloud/devices'),
+
+  // Smart Plugs
+  getSmartPlugs: () => request<SmartPlug[]>('/smart-plugs/'),
+  getSmartPlug: (id: number) => request<SmartPlug>(`/smart-plugs/${id}`),
+  createSmartPlug: (data: SmartPlugCreate) =>
+    request<SmartPlug>('/smart-plugs/', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  updateSmartPlug: (id: number, data: SmartPlugUpdate) =>
+    request<SmartPlug>(`/smart-plugs/${id}`, {
+      method: 'PATCH',
+      body: JSON.stringify(data),
+    }),
+  deleteSmartPlug: (id: number) =>
+    request<void>(`/smart-plugs/${id}`, { method: 'DELETE' }),
+  controlSmartPlug: (id: number, action: 'on' | 'off' | 'toggle') =>
+    request<{ success: boolean; action: string }>(`/smart-plugs/${id}/control`, {
+      method: 'POST',
+      body: JSON.stringify({ action }),
+    }),
+  getSmartPlugStatus: (id: number) =>
+    request<SmartPlugStatus>(`/smart-plugs/${id}/status`),
+  testSmartPlugConnection: (ip_address: string, username?: string | null, password?: string | null) =>
+    request<SmartPlugTestResult>('/smart-plugs/test-connection', {
+      method: 'POST',
+      body: JSON.stringify({ ip_address, username, password }),
+    }),
 };

+ 296 - 0
frontend/src/components/AddSmartPlugModal.tsx

@@ -0,0 +1,296 @@
+import { useState, useEffect } from 'react';
+import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
+import { X, Save, Loader2, Wifi, WifiOff, CheckCircle } from 'lucide-react';
+import { api } from '../api/client';
+import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate } from '../api/client';
+import { Button } from './Button';
+
+interface AddSmartPlugModalProps {
+  plug?: SmartPlug | null;
+  onClose: () => void;
+}
+
+export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
+  const queryClient = useQueryClient();
+  const isEditing = !!plug;
+
+  const [name, setName] = useState(plug?.name || '');
+  const [ipAddress, setIpAddress] = useState(plug?.ip_address || '');
+  const [username, setUsername] = useState(plug?.username || '');
+  const [password, setPassword] = useState(plug?.password || '');
+  const [printerId, setPrinterId] = useState<number | null>(plug?.printer_id || null);
+  const [testResult, setTestResult] = useState<{ success: boolean; state?: string | null; device_name?: string | null } | null>(null);
+  const [error, setError] = useState<string | null>(null);
+
+  // Fetch printers for linking
+  const { data: printers } = useQuery({
+    queryKey: ['printers'],
+    queryFn: api.getPrinters,
+  });
+
+  // Fetch existing plugs to check for conflicts
+  const { data: existingPlugs } = useQuery({
+    queryKey: ['smart-plugs'],
+    queryFn: api.getSmartPlugs,
+  });
+
+  // Close on Escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  // Test connection mutation
+  const testMutation = useMutation({
+    mutationFn: () => api.testSmartPlugConnection(ipAddress, username || null, password || null),
+    onSuccess: (result) => {
+      setTestResult(result);
+      setError(null);
+      // Auto-fill name from device if empty
+      if (!name && result.device_name) {
+        setName(result.device_name);
+      }
+    },
+    onError: (err: Error) => {
+      setTestResult(null);
+      setError(err.message);
+    },
+  });
+
+  // Create mutation
+  const createMutation = useMutation({
+    mutationFn: (data: SmartPlugCreate) => api.createSmartPlug(data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
+      onClose();
+    },
+    onError: (err: Error) => {
+      setError(err.message);
+    },
+  });
+
+  // Update mutation
+  const updateMutation = useMutation({
+    mutationFn: (data: SmartPlugUpdate) => api.updateSmartPlug(plug!.id, data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
+      onClose();
+    },
+    onError: (err: Error) => {
+      setError(err.message);
+    },
+  });
+
+  // Filter out printers that already have a plug assigned (except current plug's printer)
+  const availablePrinters = printers?.filter(p => {
+    const hasPlug = existingPlugs?.some(ep => ep.printer_id === p.id && ep.id !== plug?.id);
+    return !hasPlug;
+  });
+
+  const handleSubmit = (e: React.FormEvent) => {
+    e.preventDefault();
+    setError(null);
+
+    if (!name.trim()) {
+      setError('Name is required');
+      return;
+    }
+    if (!ipAddress.trim()) {
+      setError('IP address is required');
+      return;
+    }
+
+    const data = {
+      name: name.trim(),
+      ip_address: ipAddress.trim(),
+      username: username.trim() || null,
+      password: password.trim() || null,
+      printer_id: printerId,
+    };
+
+    if (isEditing) {
+      updateMutation.mutate(data);
+    } else {
+      createMutation.mutate(data);
+    }
+  };
+
+  const isPending = createMutation.isPending || updateMutation.isPending;
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
+      onClick={onClose}
+    >
+      <div
+        className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-md"
+        onClick={(e) => e.stopPropagation()}
+      >
+        {/* Header */}
+        <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
+          <h2 className="text-lg font-semibold text-white">
+            {isEditing ? 'Edit Smart Plug' : 'Add Smart Plug'}
+          </h2>
+          <button
+            onClick={onClose}
+            className="text-bambu-gray hover:text-white transition-colors"
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        {/* Form */}
+        <form onSubmit={handleSubmit} className="p-6 space-y-4">
+          {error && (
+            <div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
+              {error}
+            </div>
+          )}
+
+          {/* IP Address */}
+          <div>
+            <label className="block text-sm text-bambu-gray mb-1">IP Address *</label>
+            <div className="flex gap-2">
+              <input
+                type="text"
+                value={ipAddress}
+                onChange={(e) => {
+                  setIpAddress(e.target.value);
+                  setTestResult(null);
+                }}
+                placeholder="192.168.1.100"
+                className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+              />
+              <Button
+                type="button"
+                variant="secondary"
+                onClick={() => testMutation.mutate()}
+                disabled={!ipAddress.trim() || testMutation.isPending}
+              >
+                {testMutation.isPending ? (
+                  <Loader2 className="w-4 h-4 animate-spin" />
+                ) : (
+                  <Wifi className="w-4 h-4" />
+                )}
+                Test
+              </Button>
+            </div>
+          </div>
+
+          {/* Test Result */}
+          {testResult && (
+            <div className={`p-3 rounded-lg flex items-center gap-2 ${
+              testResult.success
+                ? 'bg-bambu-green/20 border border-bambu-green/50 text-bambu-green'
+                : 'bg-red-500/20 border border-red-500/50 text-red-400'
+            }`}>
+              {testResult.success ? (
+                <>
+                  <CheckCircle className="w-5 h-5" />
+                  <div>
+                    <p className="font-medium">Connected!</p>
+                    <p className="text-sm opacity-80">
+                      {testResult.device_name && `Device: ${testResult.device_name} - `}
+                      State: {testResult.state}
+                    </p>
+                  </div>
+                </>
+              ) : (
+                <>
+                  <WifiOff className="w-5 h-5" />
+                  <span>Connection failed</span>
+                </>
+              )}
+            </div>
+          )}
+
+          {/* Name */}
+          <div>
+            <label className="block text-sm text-bambu-gray mb-1">Name *</label>
+            <input
+              type="text"
+              value={name}
+              onChange={(e) => setName(e.target.value)}
+              placeholder="Living Room Plug"
+              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>
+
+          {/* Authentication (optional) */}
+          <div className="grid grid-cols-2 gap-3">
+            <div>
+              <label className="block text-sm text-bambu-gray mb-1">Username</label>
+              <input
+                type="text"
+                value={username}
+                onChange={(e) => setUsername(e.target.value)}
+                placeholder="admin"
+                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">Password</label>
+              <input
+                type="password"
+                value={password}
+                onChange={(e) => setPassword(e.target.value)}
+                placeholder="********"
+                className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+              />
+            </div>
+          </div>
+          <p className="text-xs text-bambu-gray -mt-2">
+            Leave empty if your Tasmota device doesn't require authentication
+          </p>
+
+          {/* 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>
+
+          {/* Actions */}
+          <div className="flex gap-3 pt-2">
+            <Button
+              type="button"
+              variant="secondary"
+              onClick={onClose}
+              className="flex-1"
+            >
+              Cancel
+            </Button>
+            <Button
+              type="submit"
+              disabled={isPending}
+              className="flex-1"
+            >
+              {isPending ? (
+                <Loader2 className="w-4 h-4 animate-spin" />
+              ) : (
+                <Save className="w-4 h-4" />
+              )}
+              {isEditing ? 'Save' : 'Add'}
+            </Button>
+          </div>
+        </form>
+      </div>
+    </div>
+  );
+}

+ 296 - 0
frontend/src/components/SmartPlugCard.tsx

@@ -0,0 +1,296 @@
+import { useState } from 'react';
+import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
+import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2 } from 'lucide-react';
+import { api } from '../api/client';
+import type { SmartPlug, SmartPlugUpdate } from '../api/client';
+import { Card, CardContent } from './Card';
+import { Button } from './Button';
+import { ConfirmModal } from './ConfirmModal';
+
+interface SmartPlugCardProps {
+  plug: SmartPlug;
+  onEdit: (plug: SmartPlug) => void;
+}
+
+export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
+  const queryClient = useQueryClient();
+  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  // Fetch current status
+  const { data: status, isLoading: statusLoading, refetch: refetchStatus } = useQuery({
+    queryKey: ['smart-plug-status', plug.id],
+    queryFn: () => api.getSmartPlugStatus(plug.id),
+    refetchInterval: 30000, // Refresh every 30 seconds
+  });
+
+  // Fetch printers for linking
+  const { data: printers } = useQuery({
+    queryKey: ['printers'],
+    queryFn: api.getPrinters,
+  });
+
+  const linkedPrinter = printers?.find(p => p.id === plug.printer_id);
+
+  // Control mutation
+  const controlMutation = useMutation({
+    mutationFn: (action: 'on' | 'off' | 'toggle') => api.controlSmartPlug(plug.id, action),
+    onSuccess: () => {
+      refetchStatus();
+    },
+  });
+
+  // Update mutation
+  const updateMutation = useMutation({
+    mutationFn: (data: SmartPlugUpdate) => api.updateSmartPlug(plug.id, data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
+    },
+  });
+
+  // Delete mutation
+  const deleteMutation = useMutation({
+    mutationFn: () => api.deleteSmartPlug(plug.id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
+    },
+  });
+
+  const isOn = status?.state === 'ON';
+  const isReachable = status?.reachable ?? false;
+  const isPending = controlMutation.isPending;
+
+  return (
+    <>
+      <Card className="relative">
+        <CardContent className="p-4">
+          {/* 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 className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
+              </div>
+              <div>
+                <h3 className="font-medium text-white">{plug.name}</h3>
+                <p className="text-sm text-bambu-gray">{plug.ip_address}</p>
+              </div>
+            </div>
+
+            {/* Status indicator */}
+            <div className="flex items-center gap-2">
+              {statusLoading ? (
+                <Loader2 className="w-4 h-4 text-bambu-gray animate-spin" />
+              ) : isReachable ? (
+                <div className="flex items-center gap-1 text-sm">
+                  <Wifi className="w-4 h-4 text-bambu-green" />
+                  <span className={isOn ? 'text-bambu-green' : 'text-bambu-gray'}>{status?.state || 'Unknown'}</span>
+                </div>
+              ) : (
+                <div className="flex items-center gap-1 text-sm text-red-400">
+                  <WifiOff className="w-4 h-4" />
+                  <span>Offline</span>
+                </div>
+              )}
+            </div>
+          </div>
+
+          {/* Linked Printer */}
+          {linkedPrinter && (
+            <div className="mb-3 px-2 py-1.5 bg-bambu-dark rounded-lg">
+              <span className="text-xs text-bambu-gray">Linked to: </span>
+              <span className="text-sm text-white">{linkedPrinter.name}</span>
+            </div>
+          )}
+
+          {/* Quick Controls */}
+          <div className="flex gap-2 mb-3">
+            <Button
+              size="sm"
+              variant={isOn ? 'primary' : 'secondary'}
+              disabled={!isReachable || isPending}
+              onClick={() => controlMutation.mutate('on')}
+              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={() => controlMutation.mutate('off')}
+              className="flex-1"
+            >
+              {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
+              Off
+            </Button>
+          </div>
+
+          {/* Toggle Settings Panel */}
+          <button
+            onClick={() => setIsExpanded(!isExpanded)}
+            className="w-full flex items-center justify-between py-2 text-sm text-bambu-gray hover:text-white transition-colors"
+          >
+            <span className="flex items-center gap-2">
+              <Settings2 className="w-4 h-4" />
+              Automation Settings
+            </span>
+            <span>{isExpanded ? '-' : '+'}</span>
+          </button>
+
+          {/* Expanded Settings */}
+          {isExpanded && (
+            <div className="pt-3 border-t border-bambu-dark-tertiary space-y-4">
+              {/* 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 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</p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={plug.auto_off}
+                    onChange={(e) => updateMutation.mutate({ auto_off: 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>
+
+              {/* Delay Mode */}
+              {plug.auto_off && (
+                <div className="space-y-3 pl-4 border-l-2 border-bambu-dark-tertiary">
+                  <div>
+                    <p className="text-sm text-white mb-2">Turn Off Delay Mode</p>
+                    <div className="flex gap-2">
+                      <button
+                        onClick={() => updateMutation.mutate({ off_delay_mode: 'time' })}
+                        className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
+                          plug.off_delay_mode === 'time'
+                            ? 'bg-bambu-green text-white'
+                            : 'bg-bambu-dark text-bambu-gray hover:text-white'
+                        }`}
+                      >
+                        <Clock className="w-4 h-4" />
+                        Time
+                      </button>
+                      <button
+                        onClick={() => updateMutation.mutate({ off_delay_mode: 'temperature' })}
+                        className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
+                          plug.off_delay_mode === 'temperature'
+                            ? 'bg-bambu-green text-white'
+                            : 'bg-bambu-dark text-bambu-gray hover:text-white'
+                        }`}
+                      >
+                        <Thermometer className="w-4 h-4" />
+                        Temp
+                      </button>
+                    </div>
+                  </div>
+
+                  {plug.off_delay_mode === 'time' ? (
+                    <div>
+                      <label className="block text-xs text-bambu-gray mb-1">Delay (minutes)</label>
+                      <input
+                        type="number"
+                        min="1"
+                        max="60"
+                        value={plug.off_delay_minutes}
+                        onChange={(e) => updateMutation.mutate({ off_delay_minutes: parseInt(e.target.value) || 5 })}
+                        className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
+                      />
+                    </div>
+                  ) : (
+                    <div>
+                      <label className="block text-xs text-bambu-gray mb-1">Temperature threshold (C)</label>
+                      <input
+                        type="number"
+                        min="30"
+                        max="100"
+                        value={plug.off_temp_threshold}
+                        onChange={(e) => updateMutation.mutate({ off_temp_threshold: parseInt(e.target.value) || 70 })}
+                        className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
+                      />
+                      <p className="text-xs text-bambu-gray mt-1">Turns off when nozzle cools below this temperature</p>
+                    </div>
+                  )}
+                </div>
+              )}
+
+              {/* Action Buttons */}
+              <div className="flex gap-2 pt-2">
+                <Button
+                  size="sm"
+                  variant="secondary"
+                  onClick={() => onEdit(plug)}
+                  className="flex-1"
+                >
+                  <Edit2 className="w-4 h-4" />
+                  Edit
+                </Button>
+                <Button
+                  size="sm"
+                  variant="secondary"
+                  onClick={() => setShowDeleteConfirm(true)}
+                  className="text-red-400 hover:text-red-300"
+                >
+                  <Trash2 className="w-4 h-4" />
+                </Button>
+              </div>
+            </div>
+          )}
+        </CardContent>
+      </Card>
+
+      {/* Delete Confirmation */}
+      {showDeleteConfirm && (
+        <ConfirmModal
+          title="Delete Smart Plug"
+          message={`Are you sure you want to delete "${plug.name}"? This cannot be undone.`}
+          confirmText="Delete"
+          variant="danger"
+          onConfirm={() => {
+            deleteMutation.mutate();
+            setShowDeleteConfirm(false);
+          }}
+          onCancel={() => setShowDeleteConfirm(false)}
+        />
+      )}
+    </>
+  );
+}

+ 179 - 143
frontend/src/pages/SettingsPage.tsx

@@ -1,10 +1,11 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Save, RotateCcw, Loader2, Check } from 'lucide-react';
+import { Save, Loader2, Check, Plus, Plug } from 'lucide-react';
 import { api } from '../api/client';
-import type { AppSettings } from '../api/client';
+import type { AppSettings, SmartPlug } from '../api/client';
 import { Card, CardContent, CardHeader } from '../components/Card';
 import { Button } from '../components/Button';
-import { ConfirmModal } from '../components/ConfirmModal';
+import { SmartPlugCard } from '../components/SmartPlugCard';
+import { AddSmartPlugModal } from '../components/AddSmartPlugModal';
 import { useState, useEffect } from 'react';
 
 export function SettingsPage() {
@@ -12,13 +13,19 @@ export function SettingsPage() {
   const [localSettings, setLocalSettings] = useState<AppSettings | null>(null);
   const [hasChanges, setHasChanges] = useState(false);
   const [showSaved, setShowSaved] = useState(false);
-  const [showResetConfirm, setShowResetConfirm] = useState(false);
+  const [showPlugModal, setShowPlugModal] = useState(false);
+  const [editingPlug, setEditingPlug] = useState<SmartPlug | null>(null);
 
   const { data: settings, isLoading } = useQuery({
     queryKey: ['settings'],
     queryFn: api.getSettings,
   });
 
+  const { data: smartPlugs, isLoading: plugsLoading } = useQuery({
+    queryKey: ['smart-plugs'],
+    queryFn: api.getSmartPlugs,
+  });
+
   // Sync local state when settings load
   useEffect(() => {
     if (settings && !localSettings) {
@@ -49,25 +56,12 @@ export function SettingsPage() {
     },
   });
 
-  const resetMutation = useMutation({
-    mutationFn: api.resetSettings,
-    onSuccess: (data) => {
-      queryClient.setQueryData(['settings'], data);
-      setLocalSettings(data);
-      setHasChanges(false);
-    },
-  });
-
   const handleSave = () => {
     if (localSettings) {
       updateMutation.mutate(localSettings);
     }
   };
 
-  const handleReset = () => {
-    setShowResetConfirm(true);
-  };
-
   const updateSetting = <K extends keyof AppSettings>(key: K, value: AppSettings[K]) => {
     if (localSettings) {
       setLocalSettings({ ...localSettings, [key]: value });
@@ -89,29 +83,19 @@ export function SettingsPage() {
           <h1 className="text-2xl font-bold text-white">Settings</h1>
           <p className="text-bambu-gray">Configure Bambusy</p>
         </div>
-        <div className="flex gap-3">
-          <Button
-            variant="secondary"
-            onClick={handleReset}
-            disabled={resetMutation.isPending}
-          >
-            <RotateCcw className="w-4 h-4" />
-            Reset
-          </Button>
-          <Button
-            onClick={handleSave}
-            disabled={!hasChanges || updateMutation.isPending}
-          >
-            {updateMutation.isPending ? (
-              <Loader2 className="w-4 h-4 animate-spin" />
-            ) : showSaved ? (
-              <Check className="w-4 h-4" />
-            ) : (
-              <Save className="w-4 h-4" />
-            )}
-            {showSaved ? 'Saved!' : 'Save'}
-          </Button>
-        </div>
+        <Button
+          onClick={handleSave}
+          disabled={!hasChanges || updateMutation.isPending}
+        >
+          {updateMutation.isPending ? (
+            <Loader2 className="w-4 h-4 animate-spin" />
+          ) : showSaved ? (
+            <Check className="w-4 h-4" />
+          ) : (
+            <Save className="w-4 h-4" />
+          )}
+          {showSaved ? 'Saved!' : 'Save'}
+        </Button>
       </div>
 
       {updateMutation.isError && (
@@ -120,119 +104,171 @@ export function SettingsPage() {
         </div>
       )}
 
-      <div className="space-y-6 max-w-2xl">
-        <Card>
-          <CardHeader>
-            <h2 className="text-lg font-semibold text-white">Archive Settings</h2>
-          </CardHeader>
-          <CardContent className="space-y-4">
-            <div className="flex items-center justify-between">
-              <div>
-                <p className="text-white">Auto-archive prints</p>
-                <p className="text-sm text-bambu-gray">
-                  Automatically save 3MF files when prints complete
-                </p>
+      <div className="flex gap-8">
+        {/* Left Column - General Settings */}
+        <div className="space-y-6 flex-1 max-w-xl">
+          <Card>
+            <CardHeader>
+              <h2 className="text-lg font-semibold text-white">Archive Settings</h2>
+            </CardHeader>
+            <CardContent className="space-y-4">
+              <div className="flex items-center justify-between">
+                <div>
+                  <p className="text-white">Auto-archive prints</p>
+                  <p className="text-sm text-bambu-gray">
+                    Automatically save 3MF files when prints complete
+                  </p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={localSettings.auto_archive}
+                    onChange={(e) => updateSetting('auto_archive', e.target.checked)}
+                    className="sr-only peer"
+                  />
+                  <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
               </div>
-              <label className="relative inline-flex items-center cursor-pointer">
+              <div className="flex items-center justify-between">
+                <div>
+                  <p className="text-white">Save thumbnails</p>
+                  <p className="text-sm text-bambu-gray">
+                    Extract and save preview images from 3MF files
+                  </p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={localSettings.save_thumbnails}
+                    onChange={(e) => updateSetting('save_thumbnails', 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>
+            </CardContent>
+          </Card>
+
+          <Card>
+            <CardHeader>
+              <h2 className="text-lg font-semibold text-white">Cost Tracking</h2>
+            </CardHeader>
+            <CardContent className="space-y-4">
+              <div>
+                <label className="block text-sm text-bambu-gray mb-1">
+                  Default filament cost (per kg)
+                </label>
                 <input
-                  type="checkbox"
-                  checked={localSettings.auto_archive}
-                  onChange={(e) => updateSetting('auto_archive', e.target.checked)}
-                  className="sr-only peer"
+                  type="number"
+                  step="0.01"
+                  min="0"
+                  value={localSettings.default_filament_cost}
+                  onChange={(e) =>
+                    updateSetting('default_filament_cost', parseFloat(e.target.value) || 0)
+                  }
+                  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 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 className="flex items-center justify-between">
+              </div>
               <div>
-                <p className="text-white">Save thumbnails</p>
-                <p className="text-sm text-bambu-gray">
-                  Extract and save preview images from 3MF files
+                <label className="block text-sm text-bambu-gray mb-1">Currency</label>
+                <select
+                  value={localSettings.currency}
+                  onChange={(e) => updateSetting('currency', 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"
+                >
+                  <option value="USD">USD ($)</option>
+                  <option value="EUR">EUR (€)</option>
+                  <option value="GBP">GBP (£)</option>
+                  <option value="CHF">CHF (Fr.)</option>
+                  <option value="JPY">JPY (¥)</option>
+                  <option value="CNY">CNY (¥)</option>
+                  <option value="CAD">CAD ($)</option>
+                  <option value="AUD">AUD ($)</option>
+                </select>
+              </div>
+            </CardContent>
+          </Card>
+
+          <Card>
+            <CardHeader>
+              <h2 className="text-lg font-semibold text-white">About</h2>
+            </CardHeader>
+            <CardContent>
+              <div className="space-y-2 text-sm">
+                <p className="text-white">Bambusy v0.1.2</p>
+                <p className="text-bambu-gray">
+                  Archive and manage your Bambu Lab 3MF files
+                </p>
+                <p className="text-bambu-gray">
+                  Connect to printers via LAN mode (developer mode required)
                 </p>
               </div>
-              <label className="relative inline-flex items-center cursor-pointer">
-                <input
-                  type="checkbox"
-                  checked={localSettings.save_thumbnails}
-                  onChange={(e) => updateSetting('save_thumbnails', 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>
-          </CardContent>
-        </Card>
-
-        <Card>
-          <CardHeader>
-            <h2 className="text-lg font-semibold text-white">Cost Tracking</h2>
-          </CardHeader>
-          <CardContent className="space-y-4">
-            <div>
-              <label className="block text-sm text-bambu-gray mb-1">
-                Default filament cost (per kg)
-              </label>
-              <input
-                type="number"
-                step="0.01"
-                min="0"
-                value={localSettings.default_filament_cost}
-                onChange={(e) =>
-                  updateSetting('default_filament_cost', parseFloat(e.target.value) || 0)
-                }
-                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">Currency</label>
-              <select
-                value={localSettings.currency}
-                onChange={(e) => updateSetting('currency', 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"
-              >
-                <option value="USD">USD ($)</option>
-                <option value="EUR">EUR (€)</option>
-                <option value="GBP">GBP (£)</option>
-                <option value="CHF">CHF (Fr.)</option>
-                <option value="JPY">JPY (¥)</option>
-                <option value="CNY">CNY (¥)</option>
-                <option value="CAD">CAD ($)</option>
-                <option value="AUD">AUD ($)</option>
-              </select>
-            </div>
-          </CardContent>
-        </Card>
-
-        <Card>
-          <CardHeader>
-            <h2 className="text-lg font-semibold text-white">About</h2>
-          </CardHeader>
-          <CardContent>
-            <div className="space-y-2 text-sm">
-              <p className="text-white">Bambusy v0.1.0</p>
-              <p className="text-bambu-gray">
-                Archive and manage your Bambu Lab 3MF files
-              </p>
-              <p className="text-bambu-gray">
-                Connect to printers via LAN mode (developer mode required)
+            </CardContent>
+          </Card>
+        </div>
+
+        {/* Right Column - Smart Plugs */}
+        <div className="w-96 flex-shrink-0">
+          <Card>
+            <CardHeader>
+              <div className="flex items-center justify-between">
+                <div className="flex items-center gap-2">
+                  <Plug className="w-5 h-5 text-bambu-green" />
+                  <h2 className="text-lg font-semibold text-white">Smart Plugs</h2>
+                </div>
+                <Button
+                  size="sm"
+                  onClick={() => {
+                    setEditingPlug(null);
+                    setShowPlugModal(true);
+                  }}
+                >
+                  <Plus className="w-4 h-4" />
+                  Add
+                </Button>
+              </div>
+            </CardHeader>
+            <CardContent>
+              <p className="text-sm text-bambu-gray mb-4">
+                Connect Tasmota-based smart plugs to automate power control for your printers.
               </p>
-            </div>
-          </CardContent>
-        </Card>
+              {plugsLoading ? (
+                <div className="flex justify-center py-8">
+                  <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
+                </div>
+              ) : smartPlugs && smartPlugs.length > 0 ? (
+                <div className="space-y-4">
+                  {smartPlugs.map((plug) => (
+                    <SmartPlugCard
+                      key={plug.id}
+                      plug={plug}
+                      onEdit={(p) => {
+                        setEditingPlug(p);
+                        setShowPlugModal(true);
+                      }}
+                    />
+                  ))}
+                </div>
+              ) : (
+                <div className="text-center py-8 text-bambu-gray">
+                  <Plug className="w-12 h-12 mx-auto mb-3 opacity-30" />
+                  <p>No smart plugs configured</p>
+                  <p className="text-sm mt-1">Add a Tasmota plug to get started</p>
+                </div>
+              )}
+            </CardContent>
+          </Card>
+        </div>
       </div>
 
-      {/* Reset Confirmation Modal */}
-      {showResetConfirm && (
-        <ConfirmModal
-          title="Reset Settings"
-          message="Reset all settings to defaults? This cannot be undone."
-          confirmText="Reset"
-          variant="danger"
-          onConfirm={() => {
-            resetMutation.mutate();
-            setShowResetConfirm(false);
+      {/* Smart Plug Modal */}
+      {showPlugModal && (
+        <AddSmartPlugModal
+          plug={editingPlug}
+          onClose={() => {
+            setShowPlugModal(false);
+            setEditingPlug(null);
           }}
-          onCancel={() => setShowResetConfirm(false)}
         />
       )}
     </div>

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


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


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


+ 2 - 2
static/index.html

@@ -7,8 +7,8 @@
     <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
     <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
     <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
-    <script type="module" crossorigin src="/assets/index-BWObUI22.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DUSeheuZ.css">
+    <script type="module" crossorigin src="/assets/index-Qa2rW77Z.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-BVoz8SLN.css">
   </head>
   <body>
     <div id="root"></div>

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