Просмотр исходного кода

Added support for Tasmota based smart power plugs and automation

Martin Ziegler 9 месяцев назад
Родитель
Сommit
5c27da41c5

+ 66 - 0
README.md

@@ -25,6 +25,11 @@
 - **Automatic Print Archiving** - Automatically saves 3MF files when prints complete
 - **Automatic Print Archiving** - Automatically saves 3MF files when prints complete
 - **3D Model Preview** - Interactive Three.js viewer for archived prints
 - **3D Model Preview** - Interactive Three.js viewer for archived prints
 - **Real-time Monitoring** - Live printer status via WebSocket with print progress, temperatures, and more
 - **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 Statistics Dashboard** - Customizable dashboard with drag-and-drop widgets
   - Print success rates
   - Print success rates
   - Filament usage trends
   - 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
 4. Click "Edit" to modify the project page metadata
 5. Changes are saved directly to the 3MF file
 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
 ## Tech Stack
 
 
 - **Backend**: Python / FastAPI
 - **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
 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
 ## Known Issues / Roadmap
 
 
 ### Beta Limitations
 ### Beta Limitations
@@ -422,6 +487,7 @@ sudo journalctl -u bambusy -f
 - [ ] Timelapse video integration
 - [ ] Timelapse video integration
 - [ ] Mobile-responsive improvements
 - [ ] Mobile-responsive improvements
 - [ ] Printer groups/organization
 - [ ] Printer groups/organization
+- [x] Smart plug integration (Tasmota)
 
 
 ## License
 ## 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.config import settings as app_settings
 from backend.app.core.database import init_db, async_session
 from backend.app.core.database import init_db, async_session
 from backend.app.core.websocket import ws_manager
 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.api.routes import settings as settings_routes
 from backend.app.services.printer_manager import (
 from backend.app.services.printer_manager import (
     printer_manager,
     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.bambu_mqtt import PrinterState
 from backend.app.services.archive import ArchiveService
 from backend.app.services.archive import ArchiveService
 from backend.app.services.bambu_ftp import download_file_async
 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}
 # 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():
             if temp_path and temp_path.exists():
                 temp_path.unlink()
                 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):
 async def on_print_complete(printer_id: int, data: dict):
     """Handle print completion - update the archive status."""
     """Handle print completion - update the archive status."""
@@ -251,6 +260,15 @@ async def on_print_complete(printer_id: int, data: dict):
             "status": status,
             "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
 @asynccontextmanager
 async def lifespan(app: FastAPI):
 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(filaments.router, prefix=app_settings.api_prefix)
 app.include_router(settings_routes.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(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)
 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(
     archives: Mapped[list["PrintArchive"]] = relationship(
         back_populates="printer", cascade="all, delete-orphan"
         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.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"])
             temps["nozzle"] = float(data["nozzle_temper"])
         if "nozzle_target_temper" in data:
         if "nozzle_target_temper" in data:
             temps["nozzle_target"] = float(data["nozzle_target_temper"])
             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:
         if "chamber_temper" in data:
             temps["chamber"] = float(data["chamber_temper"])
             temps["chamber"] = float(data["chamber_temper"])
         if temps:
         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;
   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
 // API functions
 export const api = {
 export const api = {
   // Printers
   // Printers
@@ -399,4 +459,32 @@ export const api = {
   getCloudSettingDetail: (settingId: string) =>
   getCloudSettingDetail: (settingId: string) =>
     request<Record<string, unknown>>(`/cloud/settings/${settingId}`),
     request<Record<string, unknown>>(`/cloud/settings/${settingId}`),
   getCloudDevices: () => request<CloudDevice[]>('/cloud/devices'),
   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 { 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 { 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 { Card, CardContent, CardHeader } from '../components/Card';
 import { Button } from '../components/Button';
 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';
 import { useState, useEffect } from 'react';
 
 
 export function SettingsPage() {
 export function SettingsPage() {
@@ -12,13 +13,19 @@ export function SettingsPage() {
   const [localSettings, setLocalSettings] = useState<AppSettings | null>(null);
   const [localSettings, setLocalSettings] = useState<AppSettings | null>(null);
   const [hasChanges, setHasChanges] = useState(false);
   const [hasChanges, setHasChanges] = useState(false);
   const [showSaved, setShowSaved] = 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({
   const { data: settings, isLoading } = useQuery({
     queryKey: ['settings'],
     queryKey: ['settings'],
     queryFn: api.getSettings,
     queryFn: api.getSettings,
   });
   });
 
 
+  const { data: smartPlugs, isLoading: plugsLoading } = useQuery({
+    queryKey: ['smart-plugs'],
+    queryFn: api.getSmartPlugs,
+  });
+
   // Sync local state when settings load
   // Sync local state when settings load
   useEffect(() => {
   useEffect(() => {
     if (settings && !localSettings) {
     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 = () => {
   const handleSave = () => {
     if (localSettings) {
     if (localSettings) {
       updateMutation.mutate(localSettings);
       updateMutation.mutate(localSettings);
     }
     }
   };
   };
 
 
-  const handleReset = () => {
-    setShowResetConfirm(true);
-  };
-
   const updateSetting = <K extends keyof AppSettings>(key: K, value: AppSettings[K]) => {
   const updateSetting = <K extends keyof AppSettings>(key: K, value: AppSettings[K]) => {
     if (localSettings) {
     if (localSettings) {
       setLocalSettings({ ...localSettings, [key]: value });
       setLocalSettings({ ...localSettings, [key]: value });
@@ -89,29 +83,19 @@ export function SettingsPage() {
           <h1 className="text-2xl font-bold text-white">Settings</h1>
           <h1 className="text-2xl font-bold text-white">Settings</h1>
           <p className="text-bambu-gray">Configure Bambusy</p>
           <p className="text-bambu-gray">Configure Bambusy</p>
         </div>
         </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>
       </div>
 
 
       {updateMutation.isError && (
       {updateMutation.isError && (
@@ -120,119 +104,171 @@ export function SettingsPage() {
         </div>
         </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>
               </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
                 <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>
               <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>
                 </p>
               </div>
               </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>
               </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>
       </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>
     </div>

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-BVoz8SLN.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-DUSeheuZ.css


Разница между файлами не показана из-за своего большого размера
+ 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="32x32" href="/img/favicon-32x32.png" />
     <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.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" />
     <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>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Некоторые файлы не были показаны из-за большого количества измененных файлов