Files
bambuddy/backend/app/api/routes/smart_plugs.py
T
maziggy c74f24353a - Added smart plug monitoring and scheduling
- Added daily digest to notification module
2025-12-08 16:30:53 +00:00

298 lines
9.8 KiB
Python

"""API routes for smart plug management."""
import logging
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from backend.app.core.database import get_db
from backend.app.models.smart_plug import SmartPlug
from backend.app.models.printer import Printer
from backend.app.schemas.smart_plug import (
SmartPlugCreate,
SmartPlugUpdate,
SmartPlugResponse,
SmartPlugControl,
SmartPlugStatus,
SmartPlugTestConnection,
SmartPlugEnergy,
)
from backend.app.services.tasmota import tasmota_service
from backend.app.services.printer_manager import printer_manager
from backend.app.services.notification_service import notification_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/smart-plugs", tags=["smart-plugs"])
@router.get("/", response_model=list[SmartPlugResponse])
async def list_smart_plugs(db: AsyncSession = Depends(get_db)):
"""List all smart plugs."""
result = await db.execute(select(SmartPlug).order_by(SmartPlug.name))
return list(result.scalars().all())
@router.post("/", response_model=SmartPlugResponse)
async def create_smart_plug(
data: SmartPlugCreate,
db: AsyncSession = Depends(get_db),
):
"""Create a new smart plug."""
# Validate printer_id if provided
if data.printer_id:
result = await db.execute(
select(Printer).where(Printer.id == data.printer_id)
)
if not result.scalar_one_or_none():
raise HTTPException(400, "Printer not found")
# Check if printer already has a plug assigned
result = await db.execute(
select(SmartPlug).where(SmartPlug.printer_id == data.printer_id)
)
if result.scalar_one_or_none():
raise HTTPException(400, "This printer already has a smart plug assigned")
plug = SmartPlug(**data.model_dump())
db.add(plug)
await db.commit()
await db.refresh(plug)
logger.info(f"Created smart plug '{plug.name}' at {plug.ip_address}")
return plug
@router.get("/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)
)
plug = result.scalar_one_or_none()
if not plug:
return None
return plug
@router.get("/{plug_id}", response_model=SmartPlugResponse)
async def get_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)):
"""Get a specific smart plug."""
result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
plug = result.scalar_one_or_none()
if not plug:
raise HTTPException(404, "Smart plug not found")
return plug
@router.patch("/{plug_id}", response_model=SmartPlugResponse)
async def update_smart_plug(
plug_id: int,
data: SmartPlugUpdate,
db: AsyncSession = Depends(get_db),
):
"""Update a smart plug."""
result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
plug = result.scalar_one_or_none()
if not plug:
raise HTTPException(404, "Smart plug not found")
update_data = data.model_dump(exclude_unset=True)
# Validate new printer_id if being changed
if "printer_id" in update_data and update_data["printer_id"]:
new_printer_id = update_data["printer_id"]
# Check printer exists
result = await db.execute(
select(Printer).where(Printer.id == new_printer_id)
)
if not result.scalar_one_or_none():
raise HTTPException(400, "Printer not found")
# Check if that printer already has a different plug assigned
result = await db.execute(
select(SmartPlug).where(
SmartPlug.printer_id == new_printer_id,
SmartPlug.id != plug_id,
)
)
if result.scalar_one_or_none():
raise HTTPException(400, "This printer already has a smart plug assigned")
for field, value in update_data.items():
setattr(plug, field, value)
await db.commit()
await db.refresh(plug)
logger.info(f"Updated smart plug '{plug.name}'")
return plug
@router.delete("/{plug_id}")
async def delete_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)):
"""Delete a smart plug."""
result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
plug = result.scalar_one_or_none()
if not plug:
raise HTTPException(404, "Smart plug not found")
plug_name = plug.name
await db.delete(plug)
await db.commit()
logger.info(f"Deleted smart plug '{plug_name}'")
return {"message": "Smart plug deleted"}
@router.post("/{plug_id}/control")
async def control_smart_plug(
plug_id: int,
control: SmartPlugControl,
db: AsyncSession = Depends(get_db),
):
"""Manual control: on/off/toggle."""
result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
plug = result.scalar_one_or_none()
if not plug:
raise HTTPException(404, "Smart plug not found")
if control.action == "on":
success = await tasmota_service.turn_on(plug)
expected_state = "ON"
elif control.action == "off":
success = await tasmota_service.turn_off(plug)
expected_state = "OFF"
elif control.action == "toggle":
success = await tasmota_service.toggle(plug)
expected_state = None # Unknown after toggle
else:
raise HTTPException(400, f"Invalid action: {control.action}")
if not success:
raise HTTPException(503, "Failed to communicate with device")
# Update last state and reset auto_off_executed when turning on
if expected_state:
plug.last_state = expected_state
if expected_state == "ON":
plug.auto_off_executed = False # Reset flag when manually turning on
elif expected_state == "OFF" and plug.printer_id:
# Mark printer offline immediately for faster UI update
printer_manager.mark_printer_offline(plug.printer_id)
plug.last_checked = datetime.utcnow()
await db.commit()
return {"success": True, "action": control.action}
@router.get("/{plug_id}/status", response_model=SmartPlugStatus)
async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)):
"""Get current plug status from device including energy data."""
result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
plug = result.scalar_one_or_none()
if not plug:
raise HTTPException(404, "Smart plug not found")
status = await tasmota_service.get_status(plug)
# Update last state in database
if status["reachable"]:
plug.last_state = status["state"]
plug.last_checked = datetime.utcnow()
await db.commit()
# Fetch energy data if device is reachable
energy_data = None
if status["reachable"]:
energy = await tasmota_service.get_energy(plug)
if energy:
energy_data = SmartPlugEnergy(**energy)
# Check power alerts
await check_power_alerts(plug, energy.get("power"), db)
return SmartPlugStatus(
state=status["state"],
reachable=status["reachable"],
device_name=status.get("device_name"),
energy=energy_data,
)
async def check_power_alerts(plug: SmartPlug, current_power: float | None, db: AsyncSession):
"""Check if power crosses alert thresholds and send notifications."""
if not plug.power_alert_enabled or current_power is None:
return
# Cooldown: don't alert more than once per 5 minutes
cooldown_minutes = 5
if plug.power_alert_last_triggered:
time_since_last = datetime.utcnow() - plug.power_alert_last_triggered
if time_since_last < timedelta(minutes=cooldown_minutes):
return
alert_triggered = False
alert_type = None
threshold = None
# Check high threshold
if plug.power_alert_high is not None and current_power > plug.power_alert_high:
alert_triggered = True
alert_type = "high"
threshold = plug.power_alert_high
# Check low threshold
if plug.power_alert_low is not None and current_power < plug.power_alert_low:
alert_triggered = True
alert_type = "low"
threshold = plug.power_alert_low
if alert_triggered:
plug.power_alert_last_triggered = datetime.utcnow()
await db.commit()
# Send notification
title = f"Power Alert: {plug.name}"
if alert_type == "high":
message = f"Power consumption is {current_power:.1f}W, above threshold of {threshold:.1f}W"
else:
message = f"Power consumption is {current_power:.1f}W, below threshold of {threshold:.1f}W"
logger.info(f"Power alert triggered for {plug.name}: {message}")
# Use printer_error event type for power alerts (closest match)
await notification_service.send_notification(
event_type="printer_error",
title=title,
message=message,
printer_id=plug.printer_id,
printer_name=plug.name,
context={
"error_type": f"Power {alert_type.title()}",
"error_detail": message,
},
)
@router.post("/test-connection")
async def test_connection(data: SmartPlugTestConnection):
"""Test connection to a Tasmota device."""
result = await tasmota_service.test_connection(
data.ip_address,
data.username,
data.password,
)
if not result["success"]:
raise HTTPException(503, result.get("error", "Failed to connect to device"))
return {
"success": True,
"state": result["state"],
"device_name": result.get("device_name"),
}