smart_plug.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. from datetime import datetime
  2. from typing import Literal
  3. from pydantic import BaseModel, Field, model_validator
  4. class SmartPlugBase(BaseModel):
  5. name: str = Field(..., min_length=1, max_length=100)
  6. plug_type: Literal["tasmota", "homeassistant", "mqtt"] = "tasmota"
  7. # Tasmota fields (required when plug_type="tasmota")
  8. ip_address: str | None = Field(default=None, pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
  9. username: str | None = None
  10. password: str | None = None
  11. # Home Assistant fields (required when plug_type="homeassistant")
  12. ha_entity_id: str | None = Field(default=None, pattern=r"^(switch|light|input_boolean)\.[a-z0-9_]+$")
  13. # Home Assistant energy sensor entities (optional, for separate energy sensors)
  14. ha_power_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$")
  15. ha_energy_today_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$")
  16. ha_energy_total_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$")
  17. # MQTT fields (required when plug_type="mqtt")
  18. # Legacy field - kept for backward compatibility
  19. mqtt_topic: str | None = Field(default=None, max_length=200) # Deprecated, use mqtt_power_topic
  20. # Power monitoring
  21. mqtt_power_topic: str | None = Field(default=None, max_length=200) # Topic for power data
  22. mqtt_power_path: str | None = Field(default=None, max_length=100) # e.g., "power_l1" or "data.power"
  23. mqtt_power_multiplier: float = Field(default=1.0, ge=0.0001, le=10000) # Unit conversion for power
  24. # Energy monitoring
  25. mqtt_energy_topic: str | None = Field(default=None, max_length=200) # Topic for energy data
  26. mqtt_energy_path: str | None = Field(default=None, max_length=100) # e.g., "energy_l1"
  27. mqtt_energy_multiplier: float = Field(default=1.0, ge=0.0001, le=10000) # Unit conversion for energy
  28. # State monitoring
  29. mqtt_state_topic: str | None = Field(default=None, max_length=200) # Topic for state data
  30. mqtt_state_path: str | None = Field(default=None, max_length=100) # e.g., "state_l1" for ON/OFF
  31. mqtt_state_on_value: str | None = Field(
  32. default=None, max_length=50
  33. ) # What value means "ON" (e.g., "ON", "true", "1")
  34. # Legacy multiplier - kept for backward compatibility
  35. mqtt_multiplier: float = Field(default=1.0, ge=0.0001, le=10000) # Deprecated, use mqtt_power_multiplier
  36. printer_id: int | None = None
  37. enabled: bool = True
  38. auto_on: bool = True
  39. auto_off: bool = True
  40. off_delay_mode: Literal["time", "temperature"] = "time"
  41. off_delay_minutes: int = Field(default=5, ge=0, le=60)
  42. off_temp_threshold: int = Field(default=70, ge=30, le=150)
  43. # Power alerts
  44. power_alert_enabled: bool = False
  45. power_alert_high: float | None = Field(default=None, ge=0, le=5000) # Alert when power > this (watts)
  46. power_alert_low: float | None = Field(default=None, ge=0, le=5000) # Alert when power < this (watts)
  47. # Schedule
  48. schedule_enabled: bool = False
  49. schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$") # HH:MM format
  50. schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$") # HH:MM format
  51. # Switchbar visibility
  52. show_in_switchbar: bool = False
  53. @model_validator(mode="after")
  54. def validate_plug_type_fields(self) -> "SmartPlugBase":
  55. if self.plug_type == "tasmota" and not self.ip_address:
  56. raise ValueError("ip_address is required for Tasmota plugs")
  57. if self.plug_type == "homeassistant" and not self.ha_entity_id:
  58. raise ValueError("ha_entity_id is required for Home Assistant plugs")
  59. if self.plug_type == "mqtt":
  60. # Determine the effective power topic (new field takes priority, fall back to legacy)
  61. power_topic = self.mqtt_power_topic or self.mqtt_topic
  62. has_power = power_topic and self.mqtt_power_path
  63. has_energy = self.mqtt_energy_topic and self.mqtt_energy_path
  64. has_state = self.mqtt_state_topic and self.mqtt_state_path
  65. # At least one data source must be fully configured
  66. if not has_power and not has_energy and not has_state:
  67. raise ValueError(
  68. "At least one MQTT data source must be configured: "
  69. "power (topic + path), energy (topic + path), or state (topic + path)"
  70. )
  71. return self
  72. class SmartPlugCreate(SmartPlugBase):
  73. pass
  74. class SmartPlugUpdate(BaseModel):
  75. name: str | None = None
  76. plug_type: Literal["tasmota", "homeassistant", "mqtt"] | None = None
  77. ip_address: str | None = None
  78. ha_entity_id: str | None = None
  79. # Home Assistant energy sensor entities (optional)
  80. ha_power_entity: str | None = None
  81. ha_energy_today_entity: str | None = None
  82. ha_energy_total_entity: str | None = None
  83. # MQTT fields (legacy)
  84. mqtt_topic: str | None = None
  85. mqtt_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
  86. # MQTT power fields
  87. mqtt_power_topic: str | None = None
  88. mqtt_power_path: str | None = None
  89. mqtt_power_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
  90. # MQTT energy fields
  91. mqtt_energy_topic: str | None = None
  92. mqtt_energy_path: str | None = None
  93. mqtt_energy_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
  94. # MQTT state fields
  95. mqtt_state_topic: str | None = None
  96. mqtt_state_path: str | None = None
  97. mqtt_state_on_value: str | None = None
  98. printer_id: int | None = None
  99. enabled: bool | None = None
  100. auto_on: bool | None = None
  101. auto_off: bool | None = None
  102. off_delay_mode: Literal["time", "temperature"] | None = None
  103. off_delay_minutes: int | None = Field(default=None, ge=0, le=60)
  104. off_temp_threshold: int | None = Field(default=None, ge=30, le=150)
  105. username: str | None = None
  106. password: str | None = None
  107. # Power alerts
  108. power_alert_enabled: bool | None = None
  109. power_alert_high: float | None = Field(default=None, ge=0, le=5000)
  110. power_alert_low: float | None = Field(default=None, ge=0, le=5000)
  111. # Schedule
  112. schedule_enabled: bool | None = None
  113. schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
  114. schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
  115. # Switchbar visibility
  116. show_in_switchbar: bool | None = None
  117. class SmartPlugResponse(SmartPlugBase):
  118. id: int
  119. last_state: str | None = None
  120. last_checked: datetime | None = None
  121. auto_off_executed: bool = False # True when auto-off was triggered after print
  122. power_alert_last_triggered: datetime | None = None
  123. created_at: datetime
  124. updated_at: datetime
  125. class Config:
  126. from_attributes = True
  127. class SmartPlugControl(BaseModel):
  128. action: Literal["on", "off", "toggle"]
  129. class SmartPlugEnergy(BaseModel):
  130. """Energy monitoring data from a smart plug."""
  131. power: float | None = None # Current watts
  132. voltage: float | None = None # Volts
  133. current: float | None = None # Amps
  134. today: float | None = None # kWh used today
  135. yesterday: float | None = None # kWh used yesterday
  136. total: float | None = None # Total kWh
  137. factor: float | None = None # Power factor (0-1)
  138. apparent_power: float | None = None # VA
  139. reactive_power: float | None = None # VAr
  140. class SmartPlugStatus(BaseModel):
  141. state: str | None = None # "ON", "OFF", or None if unreachable
  142. reachable: bool = True
  143. device_name: str | None = None
  144. energy: SmartPlugEnergy | None = None # Energy data if available
  145. class SmartPlugTestConnection(BaseModel):
  146. ip_address: str = Field(..., pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
  147. username: str | None = None
  148. password: str | None = None
  149. # Home Assistant schemas
  150. class HATestConnectionRequest(BaseModel):
  151. """Request to test Home Assistant connection."""
  152. url: str = Field(..., min_length=1)
  153. token: str = Field(..., min_length=1)
  154. class HATestConnectionResponse(BaseModel):
  155. """Response from HA connection test."""
  156. success: bool
  157. message: str | None = None
  158. error: str | None = None
  159. class HAEntity(BaseModel):
  160. """A Home Assistant entity that can be used as a smart plug."""
  161. entity_id: str
  162. friendly_name: str
  163. state: str | None = None
  164. domain: str # "switch", "light", "input_boolean"
  165. class HASensorEntity(BaseModel):
  166. """A Home Assistant sensor entity for energy monitoring."""
  167. entity_id: str
  168. friendly_name: str
  169. state: str | None = None
  170. unit_of_measurement: str | None = None # "W", "kW", "kWh", "Wh"