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) # #1349: auto-off after AMS drying completes. Independent of `auto_off` # (print-finish). Fires whenever any AMS on the linked printer finishes # a dry cycle. auto_off_after_drying: bool = False off_delay_after_drying_minutes: int = Field(default=10, ge=0, le=120) # 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) # #1349: per-plug drying auto-off. auto_off_after_drying: bool | None = None off_delay_after_drying_minutes: int | None = Field(default=None, ge=0, le=120) 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