mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
- 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:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user