- 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.
This commit is contained in:
maziggy
2025-12-28 15:02:42 +01:00
parent b3ef6ccb32
commit 0bcedd2c16
18 changed files with 9488 additions and 8725 deletions
+3
View File
@@ -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
+133 -21
View File
@@ -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 (
SmartPlugCreate,
SmartPlugUpdate,
SmartPlugResponse,
SmartPlugControl,
SmartPlugCreate,
SmartPlugEnergy,
SmartPlugResponse,
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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()
@@ -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