smart_plug.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  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", "rest"] = "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|script)\.[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. # REST/Webhook fields (required when plug_type="rest")
  37. rest_on_url: str | None = Field(default=None, max_length=500)
  38. rest_on_body: str | None = None
  39. rest_off_url: str | None = Field(default=None, max_length=500)
  40. rest_off_body: str | None = None
  41. rest_method: Literal["GET", "POST", "PUT", "PATCH"] | None = None
  42. rest_headers: str | None = None # JSON string of custom headers
  43. rest_status_url: str | None = Field(default=None, max_length=500)
  44. rest_status_path: str | None = Field(default=None, max_length=200)
  45. rest_status_on_value: str | None = Field(default=None, max_length=50)
  46. rest_power_url: str | None = Field(default=None, max_length=500)
  47. rest_power_path: str | None = Field(default=None, max_length=200)
  48. rest_power_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)
  49. rest_energy_url: str | None = Field(default=None, max_length=500)
  50. # Today's usage, resetting at midnight.
  51. rest_energy_path: str | None = Field(default=None, max_length=200)
  52. rest_energy_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)
  53. # Lifetime counter that never resets (#2539) — a Shelly's `aenergy.total`.
  54. rest_energy_total_path: str | None = Field(default=None, max_length=200)
  55. rest_energy_total_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)
  56. printer_id: int | None = None
  57. # #2629: only a plug that really feeds the printer may mark it offline when
  58. # it switches off. Accessory plugs (filter fan, lights) are linked to a
  59. # printer purely to follow the print cycle.
  60. controls_printer_power: bool = True
  61. enabled: bool = True
  62. auto_on: bool = True
  63. auto_off: bool = True
  64. auto_off_persistent: bool = False
  65. off_delay_mode: Literal["time", "temperature"] = "time"
  66. off_delay_minutes: int = Field(default=5, ge=0, le=60)
  67. off_temp_threshold: int = Field(default=70, ge=30, le=150)
  68. # #1349: auto-off after AMS drying completes. Independent of `auto_off`
  69. # (print-finish). Fires whenever any AMS on the linked printer finishes
  70. # a dry cycle.
  71. auto_off_after_drying: bool = False
  72. off_delay_after_drying_minutes: int = Field(default=10, ge=0, le=120)
  73. # Power alerts
  74. power_alert_enabled: bool = False
  75. power_alert_high: float | None = Field(default=None, ge=0, le=5000) # Alert when power > this (watts)
  76. power_alert_low: float | None = Field(default=None, ge=0, le=5000) # Alert when power < this (watts)
  77. # Schedule
  78. schedule_enabled: bool = False
  79. schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$") # HH:MM format
  80. schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$") # HH:MM format
  81. # Visibility options
  82. show_in_switchbar: bool = False
  83. show_on_printer_card: bool = True # For scripts: show on printer card
  84. @model_validator(mode="after")
  85. def validate_plug_type_fields(self) -> "SmartPlugBase":
  86. if self.plug_type == "tasmota" and not self.ip_address:
  87. raise ValueError("ip_address is required for Tasmota plugs")
  88. if self.plug_type == "homeassistant" and not self.ha_entity_id:
  89. raise ValueError("ha_entity_id is required for Home Assistant plugs")
  90. if self.plug_type == "mqtt":
  91. # Determine the effective power topic (new field takes priority, fall back to legacy)
  92. power_topic = self.mqtt_power_topic or self.mqtt_topic
  93. # Path is optional - if not set, raw MQTT payload value will be used
  94. has_power = bool(power_topic)
  95. has_energy = bool(self.mqtt_energy_topic)
  96. has_state = bool(self.mqtt_state_topic)
  97. # At least one data source must be configured (path is optional)
  98. if not has_power and not has_energy and not has_state:
  99. raise ValueError("At least one MQTT topic must be configured for power, energy, or state monitoring")
  100. if self.plug_type == "rest":
  101. if not self.rest_on_url and not self.rest_off_url:
  102. raise ValueError("At least one of ON URL or OFF URL is required for REST plugs")
  103. return self
  104. class SmartPlugCreate(SmartPlugBase):
  105. pass
  106. class SmartPlugUpdate(BaseModel):
  107. name: str | None = None
  108. plug_type: Literal["tasmota", "homeassistant", "mqtt", "rest"] | None = None
  109. ip_address: str | None = None
  110. ha_entity_id: str | None = None
  111. # Home Assistant energy sensor entities (optional)
  112. ha_power_entity: str | None = None
  113. ha_energy_today_entity: str | None = None
  114. ha_energy_total_entity: str | None = None
  115. # MQTT fields (legacy)
  116. mqtt_topic: str | None = None
  117. mqtt_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
  118. # MQTT power fields
  119. mqtt_power_topic: str | None = None
  120. mqtt_power_path: str | None = None
  121. mqtt_power_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
  122. # MQTT energy fields
  123. mqtt_energy_topic: str | None = None
  124. mqtt_energy_path: str | None = None
  125. mqtt_energy_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
  126. # MQTT state fields
  127. mqtt_state_topic: str | None = None
  128. mqtt_state_path: str | None = None
  129. mqtt_state_on_value: str | None = None
  130. # REST fields
  131. rest_on_url: str | None = None
  132. rest_on_body: str | None = None
  133. rest_off_url: str | None = None
  134. rest_off_body: str | None = None
  135. rest_method: Literal["GET", "POST", "PUT", "PATCH"] | None = None
  136. rest_headers: str | None = None
  137. rest_status_url: str | None = None
  138. rest_status_path: str | None = None
  139. rest_status_on_value: str | None = None
  140. rest_power_url: str | None = None
  141. rest_power_path: str | None = None
  142. rest_power_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
  143. rest_energy_url: str | None = None
  144. rest_energy_path: str | None = None
  145. rest_energy_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
  146. rest_energy_total_path: str | None = None
  147. rest_energy_total_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
  148. printer_id: int | None = None
  149. # #2629: see SmartPlugBase.controls_printer_power.
  150. controls_printer_power: bool | None = None
  151. enabled: bool | None = None
  152. auto_on: bool | None = None
  153. auto_off: bool | None = None
  154. auto_off_persistent: bool | None = None
  155. off_delay_mode: Literal["time", "temperature"] | None = None
  156. off_delay_minutes: int | None = Field(default=None, ge=0, le=60)
  157. off_temp_threshold: int | None = Field(default=None, ge=30, le=150)
  158. # #1349: per-plug drying auto-off.
  159. auto_off_after_drying: bool | None = None
  160. off_delay_after_drying_minutes: int | None = Field(default=None, ge=0, le=120)
  161. username: str | None = None
  162. password: str | None = None
  163. # Power alerts
  164. power_alert_enabled: bool | None = None
  165. power_alert_high: float | None = Field(default=None, ge=0, le=5000)
  166. power_alert_low: float | None = Field(default=None, ge=0, le=5000)
  167. # Schedule
  168. schedule_enabled: bool | None = None
  169. schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
  170. schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
  171. # Visibility options
  172. show_in_switchbar: bool | None = None
  173. show_on_printer_card: bool | None = None
  174. class SmartPlugResponse(SmartPlugBase):
  175. id: int
  176. last_state: str | None = None
  177. last_checked: datetime | None = None
  178. auto_off_executed: bool = False # True when auto-off was triggered after print
  179. power_alert_last_triggered: datetime | None = None
  180. created_at: datetime
  181. updated_at: datetime
  182. class Config:
  183. from_attributes = True
  184. class SmartPlugControl(BaseModel):
  185. action: Literal["on", "off", "toggle"]
  186. class SmartPlugEnergy(BaseModel):
  187. """Energy monitoring data from a smart plug."""
  188. power: float | None = None # Current watts
  189. voltage: float | None = None # Volts
  190. current: float | None = None # Amps
  191. today: float | None = None # kWh used today
  192. yesterday: float | None = None # kWh used yesterday
  193. total: float | None = None # Total kWh
  194. factor: float | None = None # Power factor (0-1)
  195. apparent_power: float | None = None # VA
  196. reactive_power: float | None = None # VAr
  197. class SmartPlugStatus(BaseModel):
  198. state: str | None = None # "ON", "OFF", or None if unreachable
  199. reachable: bool = True
  200. device_name: str | None = None
  201. energy: SmartPlugEnergy | None = None # Energy data if available
  202. class SmartPlugTestConnection(BaseModel):
  203. ip_address: str = Field(..., pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
  204. username: str | None = None
  205. password: str | None = None
  206. # Home Assistant schemas
  207. class HATestConnectionRequest(BaseModel):
  208. """Request to test Home Assistant connection."""
  209. url: str = Field(..., min_length=1)
  210. token: str = Field(..., min_length=1)
  211. class HATestConnectionResponse(BaseModel):
  212. """Response from HA connection test."""
  213. success: bool
  214. message: str | None = None
  215. error: str | None = None
  216. class HAEntity(BaseModel):
  217. """A Home Assistant entity that can be used as a smart plug."""
  218. entity_id: str
  219. friendly_name: str
  220. state: str | None = None
  221. domain: str # "switch", "light", "input_boolean", "script"
  222. class HASensorEntity(BaseModel):
  223. """A Home Assistant sensor entity for energy monitoring."""
  224. entity_id: str
  225. friendly_name: str
  226. state: str | None = None
  227. unit_of_measurement: str | None = None # "W", "kW", "kWh", "Wh"
  228. class RESTTestConnectionRequest(BaseModel):
  229. """Request to test a REST smart plug connection."""
  230. url: str = Field(..., min_length=1)
  231. method: str = Field(default="GET")
  232. headers: str | None = None # JSON string of custom headers
  233. class RESTTestConnectionResponse(BaseModel):
  234. """Response from REST connection test."""
  235. success: bool
  236. error: str | None = None