Browse Source

- Add Tasmota device discovery and switchbar quick access
- Features:
- Tasmota device discovery: Auto-detect network and scan for Tasmota devices
in Add Smart Plug modal. Supports devices with/without authentication.
- Switchbar: Quick access widget in sidebar footer for controlling smart plugs
from anywhere in the app. Shows real-time status and power consumption.

maziggy 5 months ago
parent
commit
0bcedd2c16

+ 2 - 0
CHANGELOG.md

@@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.1.6b] - 2025-12-28
 
 ### Added
+- **Tasmota device discovery** - Automatically discover Tasmota smart plugs on your network. Click "Discover Tasmota Devices" in the Add Smart Plug modal to scan your local subnet. Supports devices with and without authentication.
+- **Switchbar for quick smart plug access** - New sidebar widget for controlling smart plugs without leaving the current page. Enable "Show in Switchbar" for any plug to add it to the quick access panel. Shows real-time status, power consumption, and on/off controls.
 - **Timelapse editor** - Edit timelapse videos with trim, speed adjustment (0.25x-4x), and music overlay. Uses FFmpeg for server-side processing with browser-based preview.
 - **AMS filament preview** - Reprint modal shows filament comparison between what the print requires and what's currently loaded in the AMS. Compares both type and color with visual indicators (green=match, yellow=color mismatch, orange=type mismatch).
 - **File type badge** - Archive cards now show GCODE (green) or SOURCE (orange) badge to indicate whether the file is a sliced print-ready file or source-only.

+ 3 - 0
backend/app/api/routes/settings.py

@@ -270,6 +270,7 @@ async def export_backup(
                     "schedule_enabled": plug.schedule_enabled,
                     "schedule_on_time": plug.schedule_on_time,
                     "schedule_off_time": plug.schedule_off_time,
+                    "show_in_switchbar": plug.show_in_switchbar,
                 }
             )
         backup["included"].append("smart_plugs")
@@ -739,6 +740,7 @@ async def import_backup(
                     existing.schedule_enabled = plug_data.get("schedule_enabled", False)
                     existing.schedule_on_time = plug_data.get("schedule_on_time")
                     existing.schedule_off_time = plug_data.get("schedule_off_time")
+                    existing.show_in_switchbar = plug_data.get("show_in_switchbar", False)
                     restored["smart_plugs"] += 1
                 else:
                     skipped["smart_plugs"] += 1
@@ -762,6 +764,7 @@ async def import_backup(
                     schedule_enabled=plug_data.get("schedule_enabled", False),
                     schedule_on_time=plug_data.get("schedule_on_time"),
                     schedule_off_time=plug_data.get("schedule_off_time"),
+                    show_in_switchbar=plug_data.get("show_in_switchbar", False),
                 )
                 db.add(plug)
                 restored["smart_plugs"] += 1

+ 132 - 20
backend/app/api/routes/smart_plugs.py

@@ -3,25 +3,27 @@
 import logging
 from datetime import datetime, timedelta
 
-from fastapi import APIRouter, Depends, HTTPException, Query
-from sqlalchemy.ext.asyncio import AsyncSession
+from fastapi import APIRouter, Body, Depends, HTTPException
+from pydantic import BaseModel
 from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
 
 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.models.smart_plug import SmartPlug
 from backend.app.schemas.smart_plug import (
+    SmartPlugControl,
     SmartPlugCreate,
-    SmartPlugUpdate,
+    SmartPlugEnergy,
     SmartPlugResponse,
-    SmartPlugControl,
     SmartPlugStatus,
     SmartPlugTestConnection,
-    SmartPlugEnergy,
+    SmartPlugUpdate,
 )
-from backend.app.services.tasmota import tasmota_service
-from backend.app.services.printer_manager import printer_manager
+from backend.app.services.discovery import tasmota_scanner
 from backend.app.services.notification_service import notification_service
+from backend.app.services.printer_manager import printer_manager
+from backend.app.services.tasmota import tasmota_service
 
 logger = logging.getLogger(__name__)
 
@@ -43,16 +45,12 @@ async def create_smart_plug(
     """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)
-        )
+        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)
-        )
+        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")
 
@@ -68,15 +66,131 @@ async def create_smart_plug(
 @router.get("/by-printer/{printer_id}", response_model=SmartPlugResponse | None)
 async def get_smart_plug_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
     """Get the smart plug assigned to a printer."""
-    result = await db.execute(
-        select(SmartPlug).where(SmartPlug.printer_id == printer_id)
-    )
+    result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
     plug = result.scalar_one_or_none()
     if not plug:
         return None
     return plug
 
 
+# Tasmota Discovery Endpoints
+# NOTE: These must be defined BEFORE /{plug_id} routes to avoid path conflicts
+
+
+class TasmotaScanRequest(BaseModel):
+    """Request to scan for Tasmota devices."""
+
+    from_ip: str | None = None  # Starting IP (auto-detected if not provided)
+    to_ip: str | None = None  # Ending IP (auto-detected if not provided)
+    timeout: float = 1.0  # Connection timeout per host
+
+
+def get_local_network_range() -> tuple[str, str]:
+    """Auto-detect local network and return IP range to scan."""
+    import socket
+
+    try:
+        # Get local IP by connecting to a public DNS (doesn't actually send data)
+        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+        s.connect(("8.8.8.8", 80))
+        local_ip = s.getsockname()[0]
+        s.close()
+
+        # Parse IP and create range (assume /24 subnet)
+        parts = local_ip.split(".")
+        base = ".".join(parts[:3])
+        from_ip = f"{base}.1"
+        to_ip = f"{base}.254"
+
+        logger.info(f"Auto-detected network: {from_ip} - {to_ip} (local IP: {local_ip})")
+        return from_ip, to_ip
+
+    except Exception as e:
+        logger.error(f"Failed to detect local network: {e}")
+        # Fallback to common home network
+        return "192.168.1.1", "192.168.1.254"
+
+
+class TasmotaScanStatus(BaseModel):
+    """Tasmota scan status response."""
+
+    running: bool
+    scanned: int
+    total: int
+
+
+class DiscoveredTasmotaDevice(BaseModel):
+    """Discovered Tasmota device."""
+
+    ip_address: str
+    name: str
+    module: int | None = None
+    state: str | None = None
+    discovered_at: str | None = None
+
+
+@router.post("/discover/scan", response_model=TasmotaScanStatus)
+async def start_tasmota_scan(request: TasmotaScanRequest | None = Body(default=None)):
+    """Start an IP range scan for Tasmota devices.
+
+    Auto-detects local network if no IP range provided.
+    """
+    import asyncio
+
+    # Auto-detect network
+    from_ip, to_ip = get_local_network_range()
+    timeout = request.timeout if request else 1.0
+
+    # Start scan in background
+    asyncio.create_task(tasmota_scanner.scan_range(from_ip, to_ip, timeout))
+
+    # Return immediate status
+    scanned, total = tasmota_scanner.progress
+    return TasmotaScanStatus(
+        running=tasmota_scanner.is_running,
+        scanned=scanned,
+        total=total,
+    )
+
+
+@router.get("/discover/status", response_model=TasmotaScanStatus)
+async def get_tasmota_scan_status():
+    """Get the current Tasmota scan status."""
+    scanned, total = tasmota_scanner.progress
+    return TasmotaScanStatus(
+        running=tasmota_scanner.is_running,
+        scanned=scanned,
+        total=total,
+    )
+
+
+@router.post("/discover/stop", response_model=TasmotaScanStatus)
+async def stop_tasmota_scan():
+    """Stop the current Tasmota scan."""
+    tasmota_scanner.stop()
+    scanned, total = tasmota_scanner.progress
+    return TasmotaScanStatus(
+        running=tasmota_scanner.is_running,
+        scanned=scanned,
+        total=total,
+    )
+
+
+@router.get("/discover/devices", response_model=list[DiscoveredTasmotaDevice])
+async def get_discovered_tasmota_devices():
+    """Get list of discovered Tasmota devices."""
+    return [
+        DiscoveredTasmotaDevice(
+            ip_address=d["ip_address"],
+            name=d["name"],
+            module=d.get("module"),
+            state=d.get("state"),
+            discovered_at=d.get("discovered_at"),
+        )
+        for d in tasmota_scanner.discovered_devices
+    ]
+
+
 @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."""
@@ -106,9 +220,7 @@ async def update_smart_plug(
         new_printer_id = update_data["printer_id"]
 
         # Check printer exists
-        result = await db.execute(
-            select(Printer).where(Printer.id == new_printer_id)
-        )
+        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")
 

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

@@ -359,6 +359,12 @@ async def run_migrations(conn):
     except Exception:
         pass
 
+    # Migration: Add show_in_switchbar column to smart_plugs
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN show_in_switchbar BOOLEAN DEFAULT 0"))
+    except Exception:
+        pass
+
 
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""

+ 9 - 5
backend/app/models/smart_plug.py

@@ -1,5 +1,6 @@
 from datetime import datetime
-from sqlalchemy import String, Boolean, Integer, Float, DateTime, ForeignKey, func
+
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
@@ -44,18 +45,21 @@ class SmartPlug(Base):
     schedule_on_time: Mapped[str | None] = mapped_column(String(5), nullable=True)  # "HH:MM" format
     schedule_off_time: Mapped[str | None] = mapped_column(String(5), nullable=True)  # "HH:MM" format
 
+    # Switchbar visibility
+    show_in_switchbar: Mapped[bool] = mapped_column(Boolean, default=False)
+
     # 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)
     auto_off_executed: Mapped[bool] = mapped_column(Boolean, default=False)  # True when auto-off was triggered
     auto_off_pending: Mapped[bool] = mapped_column(Boolean, default=False)  # True when waiting for cooldown
-    auto_off_pending_since: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)  # When auto-off was scheduled
+    auto_off_pending_since: Mapped[datetime | None] = mapped_column(
+        DateTime, nullable=True
+    )  # When auto-off was scheduled
 
     # 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()
-    )
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
 
     # Relationship
     printer: Mapped["Printer"] = relationship(back_populates="smart_plug")

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

@@ -1,5 +1,6 @@
 from datetime import datetime
 from typing import Literal
+
 from pydantic import BaseModel, Field
 
 
@@ -23,6 +24,8 @@ class SmartPlugBase(BaseModel):
     schedule_enabled: bool = False
     schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")  # HH:MM format
     schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")  # HH:MM format
+    # Switchbar visibility
+    show_in_switchbar: bool = False
 
 
 class SmartPlugCreate(SmartPlugBase):
@@ -49,6 +52,8 @@ class SmartPlugUpdate(BaseModel):
     schedule_enabled: bool | None = None
     schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
     schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
+    # Switchbar visibility
+    show_in_switchbar: bool | None = None
 
 
 class SmartPlugResponse(SmartPlugBase):
@@ -70,6 +75,7 @@ class SmartPlugControl(BaseModel):
 
 class SmartPlugEnergy(BaseModel):
     """Energy monitoring data from a smart plug."""
+
     power: float | None = None  # Current watts
     voltage: float | None = None  # Volts
     current: float | None = None  # Amps

+ 194 - 0
backend/app/services/discovery.py

@@ -479,6 +479,200 @@ class SubnetScanner:
         self._running = False
 
 
+class TasmotaScanner:
+    """Scanner for discovering Tasmota devices by probing IP addresses."""
+
+    HTTP_PORT = 80
+
+    def __init__(self):
+        self._discovered: dict[str, dict] = {}
+        self._running = False
+        self._scanned = 0
+        self._total = 0
+
+    @property
+    def is_running(self) -> bool:
+        return self._running
+
+    @property
+    def discovered_devices(self) -> list[dict]:
+        return list(self._discovered.values())
+
+    @property
+    def progress(self) -> tuple[int, int]:
+        """Return (scanned, total) counts."""
+        return self._scanned, self._total
+
+    async def scan_range(self, from_ip: str, to_ip: str, timeout: float = 1.0) -> list[dict]:
+        """Scan an IP range for Tasmota devices.
+
+        Args:
+            from_ip: Starting IP address (e.g., "192.168.1.1")
+            to_ip: Ending IP address (e.g., "192.168.1.254")
+            timeout: Connection timeout per host in seconds
+
+        Returns:
+            List of discovered Tasmota devices
+        """
+        if self._running:
+            return []
+
+        self._running = True
+        self._discovered.clear()
+        self._scanned = 0
+
+        try:
+            start = ipaddress.ip_address(from_ip)
+            end = ipaddress.ip_address(to_ip)
+
+            # Generate list of IPs in range
+            hosts = []
+            current = start
+            while current <= end:
+                hosts.append(str(current))
+                current = ipaddress.ip_address(int(current) + 1)
+
+            self._total = len(hosts)
+
+            if self._total > 1024:
+                logger.warning(f"IP range has {self._total} hosts, limiting to 1024")
+                self._total = 1024
+                hosts = hosts[:1024]
+
+            logger.info(f"Starting Tasmota scan from {from_ip} to {to_ip} ({self._total} hosts)")
+
+            # Scan in batches to avoid overwhelming the network
+            batch_size = 50
+            for i in range(0, len(hosts), batch_size):
+                if not self._running:
+                    logger.info("Tasmota scan stopped by user")
+                    break
+
+                batch = hosts[i : i + batch_size]
+                tasks = [self._probe_host(ip) for ip in batch]
+                try:
+                    await asyncio.gather(*tasks, return_exceptions=True)
+                except Exception as e:
+                    logger.warning(f"Batch {i//batch_size} error: {e}")
+                self._scanned = min(i + batch_size, len(hosts))
+
+            logger.info(f"Tasmota scan complete. Found {len(self._discovered)} devices.")
+            return self.discovered_devices
+
+        except ValueError as e:
+            logger.error(f"Invalid IP address format: {e}")
+            return []
+        finally:
+            self._running = False
+
+    async def _probe_host(self, ip: str):
+        """Probe a single host for Tasmota HTTP API."""
+        try:
+            # Hard timeout of 5 seconds max per host
+            await asyncio.wait_for(self._do_probe(ip), timeout=5.0)
+        except TimeoutError:
+            pass
+        except Exception:
+            pass
+
+    async def _do_probe(self, ip: str):
+        """Actually probe the host."""
+        import httpx
+
+        try:
+            # Reasonable timeouts for network scanning
+            client_timeout = httpx.Timeout(3.0, connect=1.0)
+            async with httpx.AsyncClient(timeout=client_timeout, follow_redirects=False) as client:
+                # First try simple Power command - most reliable indicator of Tasmota
+                power_url = f"http://{ip}/cm?cmnd=Power"
+                try:
+                    power_response = await client.get(power_url)
+                    if power_response.status_code == 401:
+                        # Device requires auth - still a Tasmota device!
+                        logger.info(f"Discovered Tasmota at {ip} (requires auth - 401)")
+                        device = {
+                            "ip_address": ip,
+                            "name": f"Tasmota ({ip})",
+                            "module": None,
+                            "state": "UNKNOWN",
+                            "discovered_at": datetime.now().isoformat(),
+                        }
+                        self._discovered[ip] = device
+                        return
+
+                    if power_response.status_code != 200:
+                        return
+
+                    power_data = power_response.json()
+
+                    # Check for Tasmota auth warning (returns 200 with WARNING)
+                    if "WARNING" in power_data:
+                        logger.info(f"Discovered Tasmota at {ip} (requires auth)")
+                        device = {
+                            "ip_address": ip,
+                            "name": f"Tasmota ({ip})",
+                            "module": None,
+                            "state": "UNKNOWN",
+                            "discovered_at": datetime.now().isoformat(),
+                        }
+                        self._discovered[ip] = device
+                        return
+
+                    # Check if response looks like Tasmota (has POWER or POWER1 key)
+                    power_state = power_data.get("POWER") or power_data.get("POWER1")
+                    if power_state is None:
+                        return
+
+                except Exception as e:
+                    logger.debug(f"Error probing {ip}: {e}")
+                    return
+
+                # It's a Tasmota device! Now get more info
+                device_name = f"Tasmota ({ip})"
+                module = None
+
+                # Try to get device name from Status 0
+                try:
+                    status_url = f"http://{ip}/cm?cmnd=Status%200"
+                    status_response = await client.get(status_url)
+                    if status_response.status_code == 200:
+                        status_data = status_response.json()
+                        if "Status" in status_data:
+                            status = status_data["Status"]
+                            device_name = status.get("DeviceName") or device_name
+                            if not device_name or device_name == f"Tasmota ({ip})":
+                                # Try FriendlyName
+                                friendly = status.get("FriendlyName")
+                                if friendly and isinstance(friendly, list) and friendly[0]:
+                                    device_name = friendly[0]
+                            module = status.get("Module")
+                except Exception:
+                    pass
+
+                device = {
+                    "ip_address": ip,
+                    "name": device_name,
+                    "module": module,
+                    "state": power_state,
+                    "discovered_at": datetime.now().isoformat(),
+                }
+
+                self._discovered[ip] = device
+                logger.info(f"Discovered Tasmota device: {device_name} at {ip}")
+
+        except httpx.TimeoutException:
+            pass
+        except httpx.ConnectError:
+            pass
+        except Exception:
+            pass
+
+    def stop(self):
+        """Stop the current scan."""
+        self._running = False
+
+
 # Global instances
 discovery_service = PrinterDiscoveryService()
 subnet_scanner = SubnetScanner()
+tasmota_scanner = TasmotaScanner()

+ 90 - 69
backend/tests/integration/test_smart_plugs_api.py

@@ -25,11 +25,9 @@ class TestSmartPlugsAPI:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_list_smart_plugs_with_data(
-        self, async_client: AsyncClient, smart_plug_factory, db_session
-    ):
+    async def test_list_smart_plugs_with_data(self, async_client: AsyncClient, smart_plug_factory, db_session):
         """Verify list returns existing plugs."""
-        plug = await smart_plug_factory(name="Test Plug 1")
+        await smart_plug_factory(name="Test Plug 1")
 
         response = await async_client.get("/api/v1/smart-plugs/")
 
@@ -64,9 +62,7 @@ class TestSmartPlugsAPI:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_create_smart_plug_with_printer(
-        self, async_client: AsyncClient, printer_factory, db_session
-    ):
+    async def test_create_smart_plug_with_printer(self, async_client: AsyncClient, printer_factory, db_session):
         """Verify smart plug can be linked to a printer."""
         printer = await printer_factory(name="Test Printer")
 
@@ -84,9 +80,7 @@ class TestSmartPlugsAPI:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_create_plug_with_invalid_printer_id(
-        self, async_client: AsyncClient
-    ):
+    async def test_create_plug_with_invalid_printer_id(self, async_client: AsyncClient):
         """Verify creating plug with non-existent printer fails."""
         data = {
             "name": "Test Plug",
@@ -105,9 +99,7 @@ class TestSmartPlugsAPI:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_get_smart_plug(
-        self, async_client: AsyncClient, smart_plug_factory, db_session
-    ):
+    async def test_get_smart_plug(self, async_client: AsyncClient, smart_plug_factory, db_session):
         """Verify single plug can be retrieved."""
         plug = await smart_plug_factory(name="Get Test Plug")
 
@@ -132,9 +124,7 @@ class TestSmartPlugsAPI:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_update_auto_off_toggle(
-        self, async_client: AsyncClient, smart_plug_factory, db_session
-    ):
+    async def test_update_auto_off_toggle(self, async_client: AsyncClient, smart_plug_factory, db_session):
         """CRITICAL: Verify auto_off toggle persists correctly.
 
         This tests the regression scenario where toggling auto_off
@@ -149,10 +139,7 @@ class TestSmartPlugsAPI:
         assert response.json()["auto_off"] is True
 
         # Toggle auto_off to False
-        response = await async_client.patch(
-            f"/api/v1/smart-plugs/{plug.id}",
-            json={"auto_off": False}
-        )
+        response = await async_client.patch(f"/api/v1/smart-plugs/{plug.id}", json={"auto_off": False})
 
         assert response.status_code == 200
         assert response.json()["auto_off"] is False
@@ -163,16 +150,11 @@ class TestSmartPlugsAPI:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_update_auto_on_toggle(
-        self, async_client: AsyncClient, smart_plug_factory, db_session
-    ):
+    async def test_update_auto_on_toggle(self, async_client: AsyncClient, smart_plug_factory, db_session):
         """Verify auto_on toggle persists correctly."""
         plug = await smart_plug_factory(auto_on=True)
 
-        response = await async_client.patch(
-            f"/api/v1/smart-plugs/{plug.id}",
-            json={"auto_on": False}
-        )
+        response = await async_client.patch(f"/api/v1/smart-plugs/{plug.id}", json={"auto_on": False})
 
         assert response.status_code == 200
         assert response.json()["auto_on"] is False
@@ -183,31 +165,23 @@ class TestSmartPlugsAPI:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_update_enabled_toggle(
-        self, async_client: AsyncClient, smart_plug_factory, db_session
-    ):
+    async def test_update_enabled_toggle(self, async_client: AsyncClient, smart_plug_factory, db_session):
         """Verify enabled toggle persists correctly."""
         plug = await smart_plug_factory(enabled=True)
 
-        response = await async_client.patch(
-            f"/api/v1/smart-plugs/{plug.id}",
-            json={"enabled": False}
-        )
+        response = await async_client.patch(f"/api/v1/smart-plugs/{plug.id}", json={"enabled": False})
 
         assert response.status_code == 200
         assert response.json()["enabled"] is False
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_update_off_delay_mode(
-        self, async_client: AsyncClient, smart_plug_factory, db_session
-    ):
+    async def test_update_off_delay_mode(self, async_client: AsyncClient, smart_plug_factory, db_session):
         """Verify off_delay_mode can be changed."""
         plug = await smart_plug_factory(off_delay_mode="time")
 
         response = await async_client.patch(
-            f"/api/v1/smart-plugs/{plug.id}",
-            json={"off_delay_mode": "temperature", "off_temp_threshold": 50}
+            f"/api/v1/smart-plugs/{plug.id}", json={"off_delay_mode": "temperature", "off_temp_threshold": 50}
         )
 
         assert response.status_code == 200
@@ -217,9 +191,7 @@ class TestSmartPlugsAPI:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_update_schedule_settings(
-        self, async_client: AsyncClient, smart_plug_factory, db_session
-    ):
+    async def test_update_schedule_settings(self, async_client: AsyncClient, smart_plug_factory, db_session):
         """Verify schedule settings can be updated."""
         plug = await smart_plug_factory(schedule_enabled=False)
 
@@ -229,7 +201,7 @@ class TestSmartPlugsAPI:
                 "schedule_enabled": True,
                 "schedule_on_time": "08:00",
                 "schedule_off_time": "22:00",
-            }
+            },
         )
 
         assert response.status_code == 200
@@ -240,9 +212,7 @@ class TestSmartPlugsAPI:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_update_multiple_fields(
-        self, async_client: AsyncClient, smart_plug_factory, db_session
-    ):
+    async def test_update_multiple_fields(self, async_client: AsyncClient, smart_plug_factory, db_session):
         """Verify multiple fields can be updated at once."""
         plug = await smart_plug_factory(
             name="Old Name",
@@ -256,7 +226,7 @@ class TestSmartPlugsAPI:
                 "name": "New Name",
                 "auto_on": False,
                 "auto_off": False,
-            }
+            },
         )
 
         assert response.status_code == 200
@@ -277,10 +247,7 @@ class TestSmartPlugsAPI:
         """Verify smart plug can be turned on."""
         plug = await smart_plug_factory()
 
-        response = await async_client.post(
-            f"/api/v1/smart-plugs/{plug.id}/control",
-            json={"action": "on"}
-        )
+        response = await async_client.post(f"/api/v1/smart-plugs/{plug.id}/control", json={"action": "on"})
 
         assert response.status_code == 200
         result = response.json()
@@ -295,10 +262,7 @@ class TestSmartPlugsAPI:
         """Verify smart plug can be turned off."""
         plug = await smart_plug_factory()
 
-        response = await async_client.post(
-            f"/api/v1/smart-plugs/{plug.id}/control",
-            json={"action": "off"}
-        )
+        response = await async_client.post(f"/api/v1/smart-plugs/{plug.id}/control", json={"action": "off"})
 
         assert response.status_code == 200
         result = response.json()
@@ -313,10 +277,7 @@ class TestSmartPlugsAPI:
         """Verify smart plug can be toggled."""
         plug = await smart_plug_factory()
 
-        response = await async_client.post(
-            f"/api/v1/smart-plugs/{plug.id}/control",
-            json={"action": "toggle"}
-        )
+        response = await async_client.post(f"/api/v1/smart-plugs/{plug.id}/control", json={"action": "toggle"})
 
         assert response.status_code == 200
         result = response.json()
@@ -325,16 +286,11 @@ class TestSmartPlugsAPI:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_control_invalid_action(
-        self, async_client: AsyncClient, smart_plug_factory, db_session
-    ):
+    async def test_control_invalid_action(self, async_client: AsyncClient, smart_plug_factory, db_session):
         """Verify invalid action returns error."""
         plug = await smart_plug_factory()
 
-        response = await async_client.post(
-            f"/api/v1/smart-plugs/{plug.id}/control",
-            json={"action": "invalid"}
-        )
+        response = await async_client.post(f"/api/v1/smart-plugs/{plug.id}/control", json={"action": "invalid"})
 
         # FastAPI returns 422 for pydantic validation errors
         assert response.status_code == 422
@@ -364,9 +320,7 @@ class TestSmartPlugsAPI:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_delete_smart_plug(
-        self, async_client: AsyncClient, smart_plug_factory, db_session
-    ):
+    async def test_delete_smart_plug(self, async_client: AsyncClient, smart_plug_factory, db_session):
         """Verify smart plug can be deleted."""
         plug = await smart_plug_factory()
         plug_id = plug.id
@@ -386,3 +340,70 @@ class TestSmartPlugsAPI:
         response = await async_client.delete("/api/v1/smart-plugs/9999")
 
         assert response.status_code == 404
+
+    # ========================================================================
+    # Switchbar visibility
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_show_in_switchbar(self, async_client: AsyncClient, smart_plug_factory, db_session):
+        """Verify show_in_switchbar toggle persists correctly."""
+        plug = await smart_plug_factory(show_in_switchbar=False)
+
+        response = await async_client.patch(f"/api/v1/smart-plugs/{plug.id}", json={"show_in_switchbar": True})
+
+        assert response.status_code == 200
+        assert response.json()["show_in_switchbar"] is True
+
+        # Verify persistence
+        response = await async_client.get(f"/api/v1/smart-plugs/{plug.id}")
+        assert response.json()["show_in_switchbar"] is True
+
+    # ========================================================================
+    # Tasmota Discovery endpoints
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tasmota_discovery_scan(self, async_client: AsyncClient):
+        """Verify Tasmota discovery scan can be started."""
+        response = await async_client.post("/api/v1/smart-plugs/discover/scan")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert "running" in data
+        assert "scanned" in data
+        assert "total" in data
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tasmota_discovery_status(self, async_client: AsyncClient):
+        """Verify Tasmota discovery status endpoint works."""
+        response = await async_client.get("/api/v1/smart-plugs/discover/status")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert "running" in data
+        assert "scanned" in data
+        assert "total" in data
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tasmota_discovery_devices(self, async_client: AsyncClient):
+        """Verify Tasmota discovered devices endpoint works."""
+        response = await async_client.get("/api/v1/smart-plugs/discover/devices")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert isinstance(data, list)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tasmota_discovery_stop(self, async_client: AsyncClient):
+        """Verify Tasmota discovery can be stopped."""
+        response = await async_client.post("/api/v1/smart-plugs/discover/stop")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert "running" in data

+ 38 - 1
frontend/src/api/client.ts

@@ -14,7 +14,11 @@ async function request<T>(
 
   if (!response.ok) {
     const error = await response.json().catch(() => ({}));
-    throw new Error(error.detail || `HTTP ${response.status}`);
+    const detail = error.detail;
+    const message = typeof detail === 'string'
+      ? detail
+      : (detail ? JSON.stringify(detail) : `HTTP ${response.status}`);
+    throw new Error(message);
   }
 
   return response.json();
@@ -659,6 +663,8 @@ export interface SmartPlug {
   schedule_enabled: boolean;
   schedule_on_time: string | null;
   schedule_off_time: string | null;
+  // Switchbar visibility
+  show_in_switchbar: boolean;
   // Status
   last_state: string | null;
   last_checked: string | null;
@@ -687,6 +693,8 @@ export interface SmartPlugCreate {
   schedule_enabled?: boolean;
   schedule_on_time?: string | null;
   schedule_off_time?: string | null;
+  // Switchbar visibility
+  show_in_switchbar?: boolean;
 }
 
 export interface SmartPlugUpdate {
@@ -709,6 +717,8 @@ export interface SmartPlugUpdate {
   schedule_enabled?: boolean;
   schedule_on_time?: string | null;
   schedule_off_time?: string | null;
+  // Switchbar visibility
+  show_in_switchbar?: boolean;
 }
 
 export interface SmartPlugEnergy {
@@ -736,6 +746,21 @@ export interface SmartPlugTestResult {
   device_name: string | null;
 }
 
+// Tasmota Discovery types
+export interface TasmotaScanStatus {
+  running: boolean;
+  scanned: number;
+  total: number;
+}
+
+export interface DiscoveredTasmotaDevice {
+  ip_address: string;
+  name: string;
+  module: number | null;
+  state: string | null;
+  discovered_at: string | null;
+}
+
 // Print Queue types
 export interface PrintQueueItem {
   id: number;
@@ -1757,6 +1782,18 @@ export const api = {
       body: JSON.stringify({ ip_address, username, password }),
     }),
 
+  // Tasmota Discovery (auto-detects network)
+  startTasmotaScan: () =>
+    fetch(`${API_BASE}/smart-plugs/discover/scan`, { method: 'POST' })
+      .then(res => res.ok ? res.json() : res.json().then(e => { throw new Error(e.detail || `HTTP ${res.status}`); })),
+  getTasmotaScanStatus: () =>
+    request<TasmotaScanStatus>('/smart-plugs/discover/status'),
+  stopTasmotaScan: () =>
+    fetch(`${API_BASE}/smart-plugs/discover/stop`, { method: 'POST' })
+      .then(res => res.ok ? res.json() : res.json().then(e => { throw new Error(e.detail || `HTTP ${res.status}`); })),
+  getDiscoveredTasmotaDevices: () =>
+    request<DiscoveredTasmotaDevice[]>('/smart-plugs/discover/devices'),
+
   // Print Queue
   getQueue: (printerId?: number, status?: string) => {
     const params = new URLSearchParams();

+ 178 - 5
frontend/src/components/AddSmartPlugModal.tsx

@@ -1,8 +1,8 @@
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useRef } from 'react';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
-import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock } from 'lucide-react';
+import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock, LayoutGrid, Search, Plug, Power } from 'lucide-react';
 import { api } from '../api/client';
-import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate } from '../api/client';
+import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate, DiscoveredTasmotaDevice } from '../api/client';
 import { Button } from './Button';
 
 interface AddSmartPlugModalProps {
@@ -32,6 +32,15 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
   const [scheduleOnTime, setScheduleOnTime] = useState<string>(plug?.schedule_on_time || '');
   const [scheduleOffTime, setScheduleOffTime] = useState<string>(plug?.schedule_off_time || '');
 
+  // Switchbar visibility
+  const [showInSwitchbar, setShowInSwitchbar] = useState(plug?.show_in_switchbar || false);
+
+  // Discovery state
+  const [isScanning, setIsScanning] = useState(false);
+  const [scanProgress, setScanProgress] = useState({ scanned: 0, total: 0 });
+  const [discoveredDevices, setDiscoveredDevices] = useState<DiscoveredTasmotaDevice[]>([]);
+  const scanPollRef = useRef<NodeJS.Timeout | null>(null);
+
   // Fetch printers for linking
   const { data: printers } = useQuery({
     queryKey: ['printers'],
@@ -44,15 +53,82 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
     queryFn: api.getSmartPlugs,
   });
 
-  // Close on Escape key
+  // Close on Escape key and cleanup scan polling
   useEffect(() => {
     const handleKeyDown = (e: KeyboardEvent) => {
       if (e.key === 'Escape') onClose();
     };
     window.addEventListener('keydown', handleKeyDown);
-    return () => window.removeEventListener('keydown', handleKeyDown);
+    return () => {
+      window.removeEventListener('keydown', handleKeyDown);
+      if (scanPollRef.current) {
+        clearInterval(scanPollRef.current);
+      }
+    };
   }, [onClose]);
 
+  // Start scanning for Tasmota devices (auto-detects network)
+  const startScan = async () => {
+    setIsScanning(true);
+    setDiscoveredDevices([]);
+    setScanProgress({ scanned: 0, total: 0 });
+    setError(null);
+
+    try {
+      await api.startTasmotaScan();
+
+      // Poll function to fetch status and devices
+      const pollStatus = async () => {
+        try {
+          const status = await api.getTasmotaScanStatus();
+          setScanProgress({ scanned: status.scanned, total: status.total });
+
+          const devices = await api.getDiscoveredTasmotaDevices();
+          setDiscoveredDevices(devices);
+
+          if (!status.running) {
+            setIsScanning(false);
+            if (scanPollRef.current) {
+              clearInterval(scanPollRef.current);
+              scanPollRef.current = null;
+            }
+          }
+        } catch (e) {
+          console.error('Polling error:', e);
+        }
+      };
+
+      // Poll immediately, then every 500ms
+      await pollStatus();
+      scanPollRef.current = setInterval(pollStatus, 500);
+    } catch (err) {
+      setIsScanning(false);
+      const errorMsg = err instanceof Error ? err.message : (typeof err === 'string' ? err : JSON.stringify(err));
+      setError(errorMsg || 'Failed to start scan');
+    }
+  };
+
+  // Stop scanning
+  const stopScan = async () => {
+    try {
+      await api.stopTasmotaScan();
+    } catch {
+      // Ignore stop errors
+    }
+    setIsScanning(false);
+    if (scanPollRef.current) {
+      clearInterval(scanPollRef.current);
+      scanPollRef.current = null;
+    }
+  };
+
+  // Select a discovered device
+  const selectDevice = (device: DiscoveredTasmotaDevice) => {
+    setIpAddress(device.ip_address);
+    setName(device.name);
+    setTestResult(null);
+  };
+
   // Test connection mutation
   const testMutation = useMutation({
     mutationFn: () => api.testSmartPlugConnection(ipAddress, username || null, password || null),
@@ -127,6 +203,8 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
       schedule_enabled: scheduleEnabled,
       schedule_on_time: scheduleOnTime || null,
       schedule_off_time: scheduleOffTime || null,
+      // Switchbar
+      show_in_switchbar: showInSwitchbar,
     };
 
     if (isEditing) {
@@ -168,6 +246,79 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
             </div>
           )}
 
+          {/* Discovery Section - only show when not editing */}
+          {!isEditing && (
+            <div className="space-y-3">
+              {/* Scan button - auto-detects network */}
+              {isScanning ? (
+                <Button type="button" variant="secondary" onClick={stopScan} className="w-full">
+                  <X className="w-4 h-4" />
+                  Stop Scanning
+                </Button>
+              ) : (
+                <Button type="button" variant="primary" onClick={startScan} className="w-full">
+                  <Search className="w-4 h-4" />
+                  Discover Tasmota Devices
+                </Button>
+              )}
+
+              {/* Progress bar */}
+              {isScanning && scanProgress.total > 0 && (
+                <div className="space-y-1">
+                  <div className="flex justify-between text-xs text-bambu-gray">
+                    <span>Scanning network...</span>
+                    <span>{scanProgress.scanned} / {scanProgress.total}</span>
+                  </div>
+                  <div className="w-full bg-bambu-dark-tertiary rounded-full h-2">
+                    <div
+                      className="bg-bambu-green h-2 rounded-full transition-all duration-300"
+                      style={{ width: `${(scanProgress.scanned / scanProgress.total) * 100}%` }}
+                    />
+                  </div>
+                </div>
+              )}
+
+              {/* Discovered devices */}
+              {discoveredDevices.length > 0 && (
+                <div className="space-y-2">
+                  <p className="text-xs text-bambu-gray">Found {discoveredDevices.length} device(s) - click to select:</p>
+                  <div className="max-h-40 overflow-y-auto space-y-1">
+                    {discoveredDevices.map((device) => (
+                      <button
+                        key={device.ip_address}
+                        type="button"
+                        onClick={() => selectDevice(device)}
+                        className="w-full flex items-center justify-between p-2 bg-bambu-dark hover:bg-bambu-dark-tertiary rounded-lg transition-colors text-left border border-bambu-dark-tertiary"
+                      >
+                        <div className="flex items-center gap-2">
+                          <Plug className="w-4 h-4 text-bambu-green" />
+                          <div>
+                            <p className="text-sm text-white">{device.name}</p>
+                            <p className="text-xs text-bambu-gray">{device.ip_address}</p>
+                          </div>
+                        </div>
+                        {device.state && (
+                          <span className={`flex items-center gap-1 text-xs ${
+                            device.state === 'ON' ? 'text-bambu-green' : 'text-bambu-gray'
+                          }`}>
+                            <Power className="w-3 h-3" />
+                            {device.state}
+                          </span>
+                        )}
+                      </button>
+                    ))}
+                  </div>
+                </div>
+              )}
+
+              {!isScanning && discoveredDevices.length === 0 && scanProgress.total > 0 && (
+                <p className="text-xs text-bambu-gray text-center py-2">
+                  No Tasmota devices found on your network
+                </p>
+              )}
+            </div>
+          )}
+
           {/* IP Address */}
           <div>
             <label className="block text-sm text-bambu-gray mb-1">IP Address *</label>
@@ -382,6 +533,28 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
             )}
           </div>
 
+          {/* Switchbar Visibility */}
+          <div className="border-t border-bambu-dark-tertiary pt-4">
+            <div className="flex items-center justify-between">
+              <div className="flex items-center gap-2">
+                <LayoutGrid className="w-4 h-4 text-bambu-green" />
+                <div>
+                  <span className="text-white font-medium">Show in Switchbar</span>
+                  <p className="text-xs text-bambu-gray">Quick access from sidebar</p>
+                </div>
+              </div>
+              <label className="relative inline-flex items-center cursor-pointer">
+                <input
+                  type="checkbox"
+                  checked={showInSwitchbar}
+                  onChange={(e) => setShowInSwitchbar(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>
+
           {/* Actions */}
           <div className="flex gap-3 pt-2">
             <Button

+ 44 - 1
frontend/src/components/Layout.tsx

@@ -1,9 +1,10 @@
 import { useState, useEffect, useCallback, useRef } from 'react';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
-import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, X, Menu, Info, type LucideIcon } from 'lucide-react';
+import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, X, Menu, Info, Plug, type LucideIcon } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
+import { SwitchbarPopover } from './SwitchbarPopover';
 import { useQuery } from '@tanstack/react-query';
 import { api } from '../api/client';
 import { getIconByName } from './IconPicker';
@@ -72,6 +73,7 @@ export function Layout() {
   });
   const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
   const [showShortcuts, setShowShortcuts] = useState(false);
+  const [showSwitchbar, setShowSwitchbar] = useState(false);
   const [sidebarOrder, setSidebarOrder] = useState<string[]>(getSidebarOrder);
   const [draggedId, setDraggedId] = useState<string | null>(null);
   const [dragOverId, setDragOverId] = useState<string | null>(null);
@@ -107,6 +109,15 @@ export function Layout() {
     queryFn: api.getExternalLinks,
   });
 
+  // Fetch smart plugs to check for switchbar items
+  const { data: smartPlugs } = useQuery({
+    queryKey: ['smart-plugs'],
+    queryFn: api.getSmartPlugs,
+    staleTime: 30 * 1000, // 30 seconds
+  });
+
+  const hasSwitchbarPlugs = smartPlugs?.some(p => p.show_in_switchbar) ?? false;
+
   // Build the unified sidebar items list
   const navItemsMap = new Map(defaultNavItems.map(item => [item.id, item]));
   const extLinksMap = new Map((externalLinks || []).map(link => [`ext-${link.id}`, link]));
@@ -457,6 +468,22 @@ export function Layout() {
                 )}
               </div>
               <div className="flex items-center gap-1">
+                {hasSwitchbarPlugs && (
+                  <div className="relative">
+                    <button
+                      onMouseEnter={() => setShowSwitchbar(true)}
+                      className={`p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
+                        showSwitchbar ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'
+                      }`}
+                      title={t('nav.smartSwitches', { defaultValue: 'Smart Switches' })}
+                    >
+                      <Plug className="w-5 h-5" />
+                    </button>
+                    {showSwitchbar && (
+                      <SwitchbarPopover onClose={() => setShowSwitchbar(false)} />
+                    )}
+                  </div>
+                )}
                 <NavLink
                   to="/system"
                   className={({ isActive }) =>
@@ -504,6 +531,22 @@ export function Layout() {
                   <ArrowUpCircle className="w-5 h-5" />
                 </button>
               )}
+              {hasSwitchbarPlugs && (
+                <div className="relative">
+                  <button
+                    onMouseEnter={() => setShowSwitchbar(true)}
+                    className={`p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
+                      showSwitchbar ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'
+                    }`}
+                    title={t('nav.smartSwitches', { defaultValue: 'Smart Switches' })}
+                  >
+                    <Plug className="w-5 h-5" />
+                  </button>
+                  {showSwitchbar && (
+                    <SwitchbarPopover onClose={() => setShowSwitchbar(false)} />
+                  )}
+                </div>
+              )}
               <NavLink
                 to="/system"
                 className={({ isActive }) =>

+ 21 - 1
frontend/src/components/SmartPlugCard.tsx

@@ -1,6 +1,6 @@
 import { useState } from 'react';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
-import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2, Bell, Calendar } from 'lucide-react';
+import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2, Bell, Calendar, LayoutGrid } from 'lucide-react';
 import { api } from '../api/client';
 import type { SmartPlug, SmartPlugUpdate } from '../api/client';
 import { Card, CardContent } from './Card';
@@ -169,6 +169,26 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
           {/* Expanded Settings */}
           {isExpanded && (
             <div className="pt-3 border-t border-bambu-dark-tertiary space-y-4">
+              {/* Show in Switchbar Toggle */}
+              <div className="flex items-center justify-between">
+                <div className="flex items-center gap-2">
+                  <LayoutGrid className="w-4 h-4 text-bambu-green" />
+                  <div>
+                    <p className="text-sm text-white">Show in Switchbar</p>
+                    <p className="text-xs text-bambu-gray">Quick access from sidebar</p>
+                  </div>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={plug.show_in_switchbar}
+                    onChange={(e) => updateMutation.mutate({ show_in_switchbar: 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>
+
               {/* Enabled Toggle */}
               <div className="flex items-center justify-between">
                 <div>

+ 142 - 0
frontend/src/components/SwitchbarPopover.tsx

@@ -0,0 +1,142 @@
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { Plug, Power, PowerOff, Loader2, Wifi, WifiOff, Zap } from 'lucide-react';
+import { api } from '../api/client';
+import type { SmartPlug } from '../api/client';
+
+interface SwitchbarPopoverProps {
+  onClose: () => void;
+}
+
+function SwitchItem({ plug }: { plug: SmartPlug }) {
+  const queryClient = useQueryClient();
+
+  // Fetch current status
+  const { data: status, isLoading: statusLoading } = useQuery({
+    queryKey: ['smart-plug-status', plug.id],
+    queryFn: () => api.getSmartPlugStatus(plug.id),
+    refetchInterval: 10000, // Refresh every 10 seconds when popover is open
+  });
+
+  // Control mutation
+  const controlMutation = useMutation({
+    mutationFn: (action: 'on' | 'off') => api.controlSmartPlug(plug.id, action),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['smart-plug-status', plug.id] });
+    },
+  });
+
+  const isOn = status?.state === 'ON';
+  const isReachable = status?.reachable ?? false;
+  const isPending = controlMutation.isPending;
+
+  return (
+    <div className="flex items-center justify-between py-2 px-3 hover:bg-bambu-dark-tertiary rounded-lg transition-colors">
+      <div className="flex items-center gap-2">
+        <div className={`p-1.5 rounded ${isReachable ? (isOn ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20'}`}>
+          <Plug className={`w-4 h-4 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
+        </div>
+        <div>
+          <p className="text-sm text-white font-medium">{plug.name}</p>
+          <div className="flex items-center gap-1 text-xs">
+            {statusLoading ? (
+              <Loader2 className="w-3 h-3 text-bambu-gray animate-spin" />
+            ) : isReachable ? (
+              <>
+                <Wifi className="w-3 h-3 text-bambu-green" />
+                <span className={isOn ? 'text-bambu-green' : 'text-bambu-gray'}>{status?.state || 'Unknown'}</span>
+                {status?.energy?.power !== null && status?.energy?.power !== undefined && (
+                  <>
+                    <span className="text-bambu-gray mx-1">|</span>
+                    <Zap className="w-3 h-3 text-yellow-400" />
+                    <span className="text-yellow-400">{Math.round(status.energy.power)}W</span>
+                  </>
+                )}
+              </>
+            ) : (
+              <>
+                <WifiOff className="w-3 h-3 text-red-400" />
+                <span className="text-red-400">Offline</span>
+              </>
+            )}
+          </div>
+        </div>
+      </div>
+
+      <div className="flex gap-1">
+        <button
+          onClick={() => controlMutation.mutate('on')}
+          disabled={!isReachable || isPending}
+          className={`p-1.5 rounded transition-colors ${
+            isOn
+              ? 'bg-bambu-green text-white'
+              : 'bg-bambu-dark text-bambu-gray hover:text-white'
+          } disabled:opacity-50 disabled:cursor-not-allowed`}
+          title="Turn On"
+        >
+          {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
+        </button>
+        <button
+          onClick={() => controlMutation.mutate('off')}
+          disabled={!isReachable || isPending}
+          className={`p-1.5 rounded transition-colors ${
+            !isOn && isReachable
+              ? 'bg-bambu-dark-tertiary text-white'
+              : 'bg-bambu-dark text-bambu-gray hover:text-white'
+          } disabled:opacity-50 disabled:cursor-not-allowed`}
+          title="Turn Off"
+        >
+          {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
+        </button>
+      </div>
+    </div>
+  );
+}
+
+export function SwitchbarPopover({ onClose }: SwitchbarPopoverProps) {
+  // Fetch all smart plugs
+  const { data: plugs, isLoading } = useQuery({
+    queryKey: ['smart-plugs'],
+    queryFn: api.getSmartPlugs,
+  });
+
+  // Filter to only show plugs with show_in_switchbar enabled
+  const switchbarPlugs = plugs?.filter(p => p.show_in_switchbar) || [];
+
+  return (
+    <div
+      className="absolute bottom-full left-0 mb-2 w-72 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-xl z-50"
+      onMouseLeave={onClose}
+    >
+      {/* Header */}
+      <div className="px-4 py-3 border-b border-bambu-dark-tertiary">
+        <h3 className="text-sm font-semibold text-white flex items-center gap-2">
+          <Plug className="w-4 h-4 text-bambu-green" />
+          Smart Switches
+        </h3>
+      </div>
+
+      {/* Content */}
+      <div className="p-2 max-h-80 overflow-y-auto">
+        {isLoading ? (
+          <div className="flex items-center justify-center py-8">
+            <Loader2 className="w-6 h-6 text-bambu-gray animate-spin" />
+          </div>
+        ) : switchbarPlugs.length === 0 ? (
+          <div className="text-center py-6 px-4">
+            <Plug className="w-8 h-8 text-bambu-gray mx-auto mb-2" />
+            <p className="text-sm text-bambu-gray">No switches in switchbar</p>
+            <p className="text-xs text-bambu-gray mt-1">
+              Enable "Show in Switchbar" in Settings &gt; Smart Plugs
+            </p>
+          </div>
+        ) : (
+          <div className="space-y-1">
+            {switchbarPlugs.map(plug => (
+              <SwitchItem key={plug.id} plug={plug} />
+            ))}
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}

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


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


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


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


+ 2 - 2
static/index.html

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

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