Files
bambuddy/backend/app/schemas/smart_plug.py
T
maziggy 76adf70fd4 Add separate power/energy URLs and multipliers for REST smart plugs (#472)
REST/Webhook smart plugs can now fetch power and energy data from
  individual URLs instead of requiring all values in a single status
  response. Each value falls back to the shared Status URL when no
  separate URL is set, preserving backward compatibility. Added power
  and energy multipliers for unit conversion (e.g. 0.001 for Wh→kWh).
2026-04-03 08:31:08 +02:00

265 lines
11 KiB
Python

from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field, model_validator
class SmartPlugBase(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
plug_type: Literal["tasmota", "homeassistant", "mqtt", "rest"] = "tasmota"
# Tasmota fields (required when plug_type="tasmota")
ip_address: str | None = Field(default=None, pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
username: str | None = None
password: str | None = None
# Home Assistant fields (required when plug_type="homeassistant")
ha_entity_id: str | None = Field(default=None, pattern=r"^(switch|light|input_boolean|script)\.[a-z0-9_]+$")
# Home Assistant energy sensor entities (optional, for separate energy sensors)
ha_power_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$")
ha_energy_today_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$")
ha_energy_total_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$")
# MQTT fields (required when plug_type="mqtt")
# Legacy field - kept for backward compatibility
mqtt_topic: str | None = Field(default=None, max_length=200) # Deprecated, use mqtt_power_topic
# Power monitoring
mqtt_power_topic: str | None = Field(default=None, max_length=200) # Topic for power data
mqtt_power_path: str | None = Field(default=None, max_length=100) # e.g., "power_l1" or "data.power"
mqtt_power_multiplier: float = Field(default=1.0, ge=0.0001, le=10000) # Unit conversion for power
# Energy monitoring
mqtt_energy_topic: str | None = Field(default=None, max_length=200) # Topic for energy data
mqtt_energy_path: str | None = Field(default=None, max_length=100) # e.g., "energy_l1"
mqtt_energy_multiplier: float = Field(default=1.0, ge=0.0001, le=10000) # Unit conversion for energy
# State monitoring
mqtt_state_topic: str | None = Field(default=None, max_length=200) # Topic for state data
mqtt_state_path: str | None = Field(default=None, max_length=100) # e.g., "state_l1" for ON/OFF
mqtt_state_on_value: str | None = Field(
default=None, max_length=50
) # What value means "ON" (e.g., "ON", "true", "1")
# Legacy multiplier - kept for backward compatibility
mqtt_multiplier: float = Field(default=1.0, ge=0.0001, le=10000) # Deprecated, use mqtt_power_multiplier
# REST/Webhook fields (required when plug_type="rest")
rest_on_url: str | None = Field(default=None, max_length=500)
rest_on_body: str | None = None
rest_off_url: str | None = Field(default=None, max_length=500)
rest_off_body: str | None = None
rest_method: Literal["GET", "POST", "PUT", "PATCH"] | None = None
rest_headers: str | None = None # JSON string of custom headers
rest_status_url: str | None = Field(default=None, max_length=500)
rest_status_path: str | None = Field(default=None, max_length=200)
rest_status_on_value: str | None = Field(default=None, max_length=50)
rest_power_url: str | None = Field(default=None, max_length=500)
rest_power_path: str | None = Field(default=None, max_length=200)
rest_power_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)
rest_energy_url: str | None = Field(default=None, max_length=500)
rest_energy_path: str | None = Field(default=None, max_length=200)
rest_energy_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)
printer_id: int | None = None
enabled: bool = True
auto_on: bool = True
auto_off: bool = True
auto_off_persistent: bool = False
off_delay_mode: Literal["time", "temperature"] = "time"
off_delay_minutes: int = Field(default=5, ge=0, le=60)
off_temp_threshold: int = Field(default=70, ge=30, le=150)
# Power alerts
power_alert_enabled: bool = False
power_alert_high: float | None = Field(default=None, ge=0, le=5000) # Alert when power > this (watts)
power_alert_low: float | None = Field(default=None, ge=0, le=5000) # Alert when power < this (watts)
# Schedule
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
# Visibility options
show_in_switchbar: bool = False
show_on_printer_card: bool = True # For scripts: show on printer card
@model_validator(mode="after")
def validate_plug_type_fields(self) -> "SmartPlugBase":
if self.plug_type == "tasmota" and not self.ip_address:
raise ValueError("ip_address is required for Tasmota plugs")
if self.plug_type == "homeassistant" and not self.ha_entity_id:
raise ValueError("ha_entity_id is required for Home Assistant plugs")
if self.plug_type == "mqtt":
# Determine the effective power topic (new field takes priority, fall back to legacy)
power_topic = self.mqtt_power_topic or self.mqtt_topic
# Path is optional - if not set, raw MQTT payload value will be used
has_power = bool(power_topic)
has_energy = bool(self.mqtt_energy_topic)
has_state = bool(self.mqtt_state_topic)
# At least one data source must be configured (path is optional)
if not has_power and not has_energy and not has_state:
raise ValueError("At least one MQTT topic must be configured for power, energy, or state monitoring")
if self.plug_type == "rest":
if not self.rest_on_url and not self.rest_off_url:
raise ValueError("At least one of ON URL or OFF URL is required for REST plugs")
return self
class SmartPlugCreate(SmartPlugBase):
pass
class SmartPlugUpdate(BaseModel):
name: str | None = None
plug_type: Literal["tasmota", "homeassistant", "mqtt", "rest"] | None = None
ip_address: str | None = None
ha_entity_id: str | None = None
# Home Assistant energy sensor entities (optional)
ha_power_entity: str | None = None
ha_energy_today_entity: str | None = None
ha_energy_total_entity: str | None = None
# MQTT fields (legacy)
mqtt_topic: str | None = None
mqtt_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
# MQTT power fields
mqtt_power_topic: str | None = None
mqtt_power_path: str | None = None
mqtt_power_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
# MQTT energy fields
mqtt_energy_topic: str | None = None
mqtt_energy_path: str | None = None
mqtt_energy_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
# MQTT state fields
mqtt_state_topic: str | None = None
mqtt_state_path: str | None = None
mqtt_state_on_value: str | None = None
# REST fields
rest_on_url: str | None = None
rest_on_body: str | None = None
rest_off_url: str | None = None
rest_off_body: str | None = None
rest_method: Literal["GET", "POST", "PUT", "PATCH"] | None = None
rest_headers: str | None = None
rest_status_url: str | None = None
rest_status_path: str | None = None
rest_status_on_value: str | None = None
rest_power_url: str | None = None
rest_power_path: str | None = None
rest_power_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
rest_energy_url: str | None = None
rest_energy_path: str | None = None
rest_energy_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
printer_id: int | None = None
enabled: bool | None = None
auto_on: bool | None = None
auto_off: bool | None = None
auto_off_persistent: bool | None = None
off_delay_mode: Literal["time", "temperature"] | None = None
off_delay_minutes: int | None = Field(default=None, ge=0, le=60)
off_temp_threshold: int | None = Field(default=None, ge=30, le=150)
username: str | None = None
password: str | None = None
# Power alerts
power_alert_enabled: bool | None = None
power_alert_high: float | None = Field(default=None, ge=0, le=5000)
power_alert_low: float | None = Field(default=None, ge=0, le=5000)
# Schedule
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$")
# Visibility options
show_in_switchbar: bool | None = None
show_on_printer_card: bool | None = None
class SmartPlugResponse(SmartPlugBase):
id: int
last_state: str | None = None
last_checked: datetime | None = None
auto_off_executed: bool = False # True when auto-off was triggered after print
power_alert_last_triggered: datetime | None = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class SmartPlugControl(BaseModel):
action: Literal["on", "off", "toggle"]
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
today: float | None = None # kWh used today
yesterday: float | None = None # kWh used yesterday
total: float | None = None # Total kWh
factor: float | None = None # Power factor (0-1)
apparent_power: float | None = None # VA
reactive_power: float | None = None # VAr
class SmartPlugStatus(BaseModel):
state: str | None = None # "ON", "OFF", or None if unreachable
reachable: bool = True
device_name: str | None = None
energy: SmartPlugEnergy | None = None # Energy data if available
class SmartPlugTestConnection(BaseModel):
ip_address: str = Field(..., pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
username: str | None = None
password: str | None = None
# Home Assistant schemas
class HATestConnectionRequest(BaseModel):
"""Request to test Home Assistant connection."""
url: str = Field(..., min_length=1)
token: str = Field(..., min_length=1)
class HATestConnectionResponse(BaseModel):
"""Response from HA connection test."""
success: bool
message: str | None = None
error: str | None = None
class HAEntity(BaseModel):
"""A Home Assistant entity that can be used as a smart plug."""
entity_id: str
friendly_name: str
state: str | None = None
domain: str # "switch", "light", "input_boolean", "script"
class HASensorEntity(BaseModel):
"""A Home Assistant sensor entity for energy monitoring."""
entity_id: str
friendly_name: str
state: str | None = None
unit_of_measurement: str | None = None # "W", "kW", "kWh", "Wh"
class RESTTestConnectionRequest(BaseModel):
"""Request to test a REST smart plug connection."""
url: str = Field(..., min_length=1)
method: str = Field(default="GET")
headers: str | None = None # JSON string of custom headers
class RESTTestConnectionResponse(BaseModel):
"""Response from REST connection test."""
success: bool
error: str | None = None