smart_plug.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. from datetime import datetime
  2. from typing import Literal
  3. from pydantic import BaseModel, Field, field_validator, model_validator
  4. from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
  5. from backend.app.services.secret_fields import SECRET_PLACEHOLDER
  6. # A REST plug's URLs and request bodies, shown only to those who may edit plugs.
  7. REST_EDITOR_FIELDS = (
  8. "rest_on_url",
  9. "rest_off_url",
  10. "rest_status_url",
  11. "rest_power_url",
  12. "rest_energy_url",
  13. "rest_on_body",
  14. "rest_off_body",
  15. )
  16. class SmartPlugBase(BaseModel):
  17. name: str = Field(..., min_length=1, max_length=100)
  18. plug_type: Literal["tasmota", "homeassistant", "mqtt", "rest"] = "tasmota"
  19. # Tasmota fields (required when plug_type="tasmota")
  20. ip_address: str | None = Field(default=None, pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
  21. username: str | None = None
  22. password: str | None = None
  23. # Home Assistant fields (required when plug_type="homeassistant")
  24. ha_entity_id: str | None = Field(default=None, pattern=r"^(switch|light|input_boolean|script)\.[a-z0-9_]+$")
  25. # Home Assistant energy sensor entities (optional, for separate energy sensors)
  26. ha_power_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$")
  27. ha_energy_today_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$")
  28. ha_energy_total_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$")
  29. # MQTT fields (required when plug_type="mqtt")
  30. # Legacy field - kept for backward compatibility
  31. mqtt_topic: str | None = Field(default=None, max_length=200) # Deprecated, use mqtt_power_topic
  32. # Power monitoring
  33. mqtt_power_topic: str | None = Field(default=None, max_length=200) # Topic for power data
  34. mqtt_power_path: str | None = Field(default=None, max_length=100) # e.g., "power_l1" or "data.power"
  35. mqtt_power_multiplier: float = Field(default=1.0, gt=0, le=10000) # Unit conversion for power
  36. # Energy monitoring
  37. mqtt_energy_topic: str | None = Field(default=None, max_length=200) # Topic for energy data
  38. mqtt_energy_path: str | None = Field(default=None, max_length=100) # e.g., "energy_l1"
  39. mqtt_energy_multiplier: float = Field(default=1.0, gt=0, le=10000) # Unit conversion for energy
  40. # State monitoring
  41. mqtt_state_topic: str | None = Field(default=None, max_length=200) # Topic for state data
  42. mqtt_state_path: str | None = Field(default=None, max_length=100) # e.g., "state_l1" for ON/OFF
  43. mqtt_state_on_value: str | None = Field(
  44. default=None, max_length=50
  45. ) # What value means "ON" (e.g., "ON", "true", "1")
  46. # Legacy multiplier - kept for backward compatibility
  47. mqtt_multiplier: float = Field(default=1.0, gt=0, le=10000) # Deprecated, use mqtt_power_multiplier
  48. # REST/Webhook fields (required when plug_type="rest")
  49. rest_on_url: str | None = Field(default=None, max_length=500)
  50. rest_on_body: str | None = None
  51. rest_off_url: str | None = Field(default=None, max_length=500)
  52. rest_off_body: str | None = None
  53. rest_method: Literal["GET", "POST", "PUT", "PATCH"] | None = None
  54. rest_headers: str | None = None # JSON string of custom headers
  55. rest_status_url: str | None = Field(default=None, max_length=500)
  56. rest_status_path: str | None = Field(default=None, max_length=200)
  57. rest_status_on_value: str | None = Field(default=None, max_length=50)
  58. rest_power_url: str | None = Field(default=None, max_length=500)
  59. rest_power_path: str | None = Field(default=None, max_length=200)
  60. rest_power_multiplier: float = Field(default=1.0, gt=0, le=10000)
  61. rest_energy_url: str | None = Field(default=None, max_length=500)
  62. # Today's usage, resetting at midnight.
  63. rest_energy_path: str | None = Field(default=None, max_length=200)
  64. rest_energy_multiplier: float = Field(default=1.0, gt=0, le=10000)
  65. # Lifetime counter that never resets (#2539) — a Shelly's `aenergy.total`.
  66. rest_energy_total_path: str | None = Field(default=None, max_length=200)
  67. rest_energy_total_multiplier: float = Field(default=1.0, gt=0, le=10000)
  68. printer_id: int | None = None
  69. # #2629: only a plug that really feeds the printer may mark it offline when
  70. # it switches off. Accessory plugs (filter fan, lights) are linked to a
  71. # printer purely to follow the print cycle.
  72. controls_printer_power: bool = True
  73. enabled: bool = True
  74. auto_on: bool = True
  75. auto_off: bool = True
  76. auto_off_persistent: bool = False
  77. off_delay_mode: Literal["time", "temperature"] = "time"
  78. off_delay_minutes: int = Field(default=5, ge=0, le=60)
  79. off_temp_threshold: int = Field(default=70, ge=30, le=150)
  80. # #1349: auto-off after AMS drying completes. Independent of `auto_off`
  81. # (print-finish). Fires whenever any AMS on the linked printer finishes
  82. # a dry cycle.
  83. auto_off_after_drying: bool = False
  84. off_delay_after_drying_minutes: int = Field(default=10, ge=0, le=120)
  85. # Power alerts
  86. power_alert_enabled: bool = False
  87. power_alert_high: float | None = Field(default=None, ge=0, le=5000) # Alert when power > this (watts)
  88. power_alert_low: float | None = Field(default=None, ge=0, le=5000) # Alert when power < this (watts)
  89. # Schedule
  90. schedule_enabled: bool = False
  91. schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$") # HH:MM format
  92. schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$") # HH:MM format
  93. # Visibility options
  94. show_in_switchbar: bool = False
  95. show_on_printer_card: bool = True # For scripts: show on printer card
  96. @model_validator(mode="after")
  97. def validate_plug_type_fields(self) -> "SmartPlugBase":
  98. if self.plug_type == "tasmota" and not self.ip_address:
  99. raise ValueError("ip_address is required for Tasmota plugs")
  100. if self.plug_type == "homeassistant" and not self.ha_entity_id:
  101. raise ValueError("ha_entity_id is required for Home Assistant plugs")
  102. if self.plug_type == "mqtt":
  103. # Determine the effective power topic (new field takes priority, fall back to legacy)
  104. power_topic = self.mqtt_power_topic or self.mqtt_topic
  105. # Path is optional - if not set, raw MQTT payload value will be used
  106. has_power = bool(power_topic)
  107. has_energy = bool(self.mqtt_energy_topic)
  108. has_state = bool(self.mqtt_state_topic)
  109. # At least one data source must be configured (path is optional)
  110. if not has_power and not has_energy and not has_state:
  111. raise ValueError("At least one MQTT topic must be configured for power, energy, or state monitoring")
  112. if self.plug_type == "rest":
  113. if not self.rest_on_url and not self.rest_off_url:
  114. raise ValueError("At least one of ON URL or OFF URL is required for REST plugs")
  115. return self
  116. class SmartPlugCreate(SmartPlugBase):
  117. pass
  118. class SmartPlugUpdate(IgnoresNullForRequiredColumns):
  119. __orm_table__ = "smart_plugs"
  120. name: str | None = None
  121. plug_type: Literal["tasmota", "homeassistant", "mqtt", "rest"] | None = None
  122. ip_address: str | None = None
  123. ha_entity_id: str | None = None
  124. # Home Assistant energy sensor entities (optional)
  125. ha_power_entity: str | None = None
  126. ha_energy_today_entity: str | None = None
  127. ha_energy_total_entity: str | None = None
  128. # MQTT fields (legacy)
  129. mqtt_topic: str | None = None
  130. mqtt_multiplier: float | None = Field(default=None, gt=0, le=10000)
  131. # MQTT power fields
  132. mqtt_power_topic: str | None = None
  133. mqtt_power_path: str | None = None
  134. mqtt_power_multiplier: float | None = Field(default=None, gt=0, le=10000)
  135. # MQTT energy fields
  136. mqtt_energy_topic: str | None = None
  137. mqtt_energy_path: str | None = None
  138. mqtt_energy_multiplier: float | None = Field(default=None, gt=0, le=10000)
  139. # MQTT state fields
  140. mqtt_state_topic: str | None = None
  141. mqtt_state_path: str | None = None
  142. mqtt_state_on_value: str | None = None
  143. # REST fields
  144. rest_on_url: str | None = None
  145. rest_on_body: str | None = None
  146. rest_off_url: str | None = None
  147. rest_off_body: str | None = None
  148. rest_method: Literal["GET", "POST", "PUT", "PATCH"] | None = None
  149. rest_headers: str | None = None
  150. rest_status_url: str | None = None
  151. rest_status_path: str | None = None
  152. rest_status_on_value: str | None = None
  153. rest_power_url: str | None = None
  154. rest_power_path: str | None = None
  155. rest_power_multiplier: float | None = Field(default=None, gt=0, le=10000)
  156. rest_energy_url: str | None = None
  157. rest_energy_path: str | None = None
  158. rest_energy_multiplier: float | None = Field(default=None, gt=0, le=10000)
  159. rest_energy_total_path: str | None = None
  160. rest_energy_total_multiplier: float | None = Field(default=None, gt=0, le=10000)
  161. printer_id: int | None = None
  162. # #2629: see SmartPlugBase.controls_printer_power.
  163. controls_printer_power: bool | None = None
  164. enabled: bool | None = None
  165. auto_on: bool | None = None
  166. auto_off: bool | None = None
  167. auto_off_persistent: bool | None = None
  168. off_delay_mode: Literal["time", "temperature"] | None = None
  169. off_delay_minutes: int | None = Field(default=None, ge=0, le=60)
  170. off_temp_threshold: int | None = Field(default=None, ge=30, le=150)
  171. # #1349: per-plug drying auto-off.
  172. auto_off_after_drying: bool | None = None
  173. off_delay_after_drying_minutes: int | None = Field(default=None, ge=0, le=120)
  174. username: str | None = None
  175. password: str | None = None
  176. # Power alerts
  177. power_alert_enabled: bool | None = None
  178. power_alert_high: float | None = Field(default=None, ge=0, le=5000)
  179. power_alert_low: float | None = Field(default=None, ge=0, le=5000)
  180. # Schedule
  181. schedule_enabled: bool | None = None
  182. schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
  183. schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
  184. # Visibility options
  185. show_in_switchbar: bool | None = None
  186. show_on_printer_card: bool | None = None
  187. class SmartPlugResponse(SmartPlugBase):
  188. id: int
  189. last_state: str | None = None
  190. last_checked: datetime | None = None
  191. auto_off_executed: bool = False # True when auto-off was triggered after print
  192. power_alert_last_triggered: datetime | None = None
  193. created_at: datetime
  194. updated_at: datetime
  195. class Config:
  196. from_attributes = True
  197. @field_validator("password", "rest_headers", mode="after")
  198. @classmethod
  199. def _write_only(cls, value: str | None) -> str | None:
  200. """Secrets are write-only: a set one is shown as the placeholder."""
  201. return SECRET_PLACEHOLDER if value else value
  202. @model_validator(mode="after")
  203. def _rest_for_editors(self) -> "SmartPlugResponse":
  204. """Readers get no REST URLs or bodies: they commonly carry the device's
  205. credential, and a plug is switched and polled by its id. After the
  206. base checks, which need them."""
  207. for field in REST_EDITOR_FIELDS:
  208. setattr(self, field, None)
  209. return self
  210. class SmartPlugResponseWithSecret(SmartPlugResponse):
  211. """SmartPlugResponse with the REST URLs and bodies. Returned only to callers
  212. who may edit plugs (or with authentication off)."""
  213. @model_validator(mode="after")
  214. def _rest_for_editors(self) -> "SmartPlugResponseWithSecret":
  215. """Editors keep them, to edit them."""
  216. return self
  217. class SmartPlugControl(BaseModel):
  218. action: Literal["on", "off", "toggle"]
  219. class SmartPlugEnergy(BaseModel):
  220. """Energy monitoring data from a smart plug."""
  221. power: float | None = None # Current watts
  222. voltage: float | None = None # Volts
  223. current: float | None = None # Amps
  224. today: float | None = None # kWh used today
  225. yesterday: float | None = None # kWh used yesterday
  226. total: float | None = None # Total kWh
  227. factor: float | None = None # Power factor (0-1)
  228. apparent_power: float | None = None # VA
  229. reactive_power: float | None = None # VAr
  230. class SmartPlugStatus(BaseModel):
  231. state: str | None = None # "ON", "OFF", or None if unreachable
  232. reachable: bool = True
  233. device_name: str | None = None
  234. energy: SmartPlugEnergy | None = None # Energy data if available
  235. class SmartPlugTestConnection(BaseModel):
  236. ip_address: str = Field(..., pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
  237. username: str | None = None
  238. password: str | None = None
  239. # The saved plug being edited: its stored password fills in the placeholder.
  240. plug_id: int | None = None
  241. # Home Assistant schemas
  242. class HATestConnectionRequest(BaseModel):
  243. """Request to test Home Assistant connection."""
  244. url: str = Field(..., min_length=1)
  245. token: str = Field(..., min_length=1)
  246. class HATestConnectionResponse(BaseModel):
  247. """Response from HA connection test."""
  248. success: bool
  249. message: str | None = None
  250. error: str | None = None
  251. class HAEntity(BaseModel):
  252. """A Home Assistant entity that can be used as a smart plug."""
  253. entity_id: str
  254. friendly_name: str
  255. state: str | None = None
  256. domain: str # "switch", "light", "input_boolean", "script"
  257. class HASensorEntity(BaseModel):
  258. """A Home Assistant sensor entity for energy monitoring."""
  259. entity_id: str
  260. friendly_name: str
  261. state: str | None = None
  262. unit_of_measurement: str | None = None # "W", "kW", "kWh", "Wh"
  263. class RESTTestConnectionRequest(BaseModel):
  264. """Request to test a REST smart plug connection."""
  265. url: str = Field(..., min_length=1)
  266. method: str = Field(default="GET")
  267. headers: str | None = None # JSON string of custom headers
  268. # The saved plug being edited: its stored headers fill in the placeholder.
  269. plug_id: int | None = None
  270. class RESTTestConnectionResponse(BaseModel):
  271. """Response from REST connection test."""
  272. success: bool
  273. error: str | None = None