rest_smart_plug.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. """Service for controlling smart plugs via generic REST/HTTP API."""
  2. import json
  3. import logging
  4. from typing import TYPE_CHECKING, Any
  5. import httpx
  6. if TYPE_CHECKING:
  7. from backend.app.models.smart_plug import SmartPlug
  8. logger = logging.getLogger(__name__)
  9. class RESTSmartPlugService:
  10. """Service for controlling smart plugs via generic REST/HTTP API.
  11. Supports any home automation platform with an HTTP API (openHAB, ioBroker, FHEM, Node-RED, etc.).
  12. """
  13. def __init__(self, timeout: float = 10.0):
  14. self.timeout = timeout
  15. @staticmethod
  16. def _url_error(url: str) -> str | None:
  17. """Return why *url* is rejected by the LAN-service policy, else None.
  18. Split out from ``_validate_url`` so ``test_connection`` can tell the
  19. user which rule the URL broke instead of a single fixed sentence.
  20. """
  21. from backend.app.api.routes._url_safety import assert_safe_lan_service_url
  22. try:
  23. assert_safe_lan_service_url(url, label="REST plug URL")
  24. except ValueError as exc:
  25. return str(exc)
  26. return None
  27. @staticmethod
  28. def _validate_url(url: str) -> bool:
  29. """Apply the shared LAN-service SSRF policy to a REST plug URL.
  30. Delegates to ``_url_safety.assert_safe_lan_service_url`` — the same
  31. guard Spoolman, the notification providers and the LAN-service
  32. settings use — rather than reimplementing a narrower check. The
  33. hand-rolled version this replaces got the policy wrong in both
  34. directions: it rejected a literal ``127.0.0.1`` (so an openHAB or
  35. Node-RED instance on the same host could only be reached by spelling
  36. it ``localhost``), while allowing every target the shared policy
  37. rejects unconditionally — Alibaba/AWS-IPv6 metadata endpoints,
  38. numeric-encoded IPs, multicast and the unspecified address — because
  39. anything that wasn't a bare IP literal fell through to ``True``.
  40. Loopback and RFC-1918 stay permitted on purpose: a REST-controlled
  41. plug bridge running next to Bambuddy is the normal topology.
  42. """
  43. return RESTSmartPlugService._url_error(url) is None
  44. def _parse_headers(self, headers_json: str | None) -> dict[str, str]:
  45. """Parse JSON string to dict of headers."""
  46. if not headers_json:
  47. return {}
  48. try:
  49. headers = json.loads(headers_json)
  50. if isinstance(headers, dict):
  51. return {str(k): str(v) for k, v in headers.items()}
  52. except (json.JSONDecodeError, TypeError):
  53. # Not the value: headers carry credentials.
  54. logger.warning("Failed to parse REST headers JSON; sending none")
  55. return {}
  56. @staticmethod
  57. def _extract_json_path(data: Any, path: str) -> Any:
  58. """Extract value using dot notation (e.g., 'state' or 'data.power.status')."""
  59. if not path:
  60. return None
  61. parts = path.split(".")
  62. current = data
  63. for part in parts:
  64. if isinstance(current, dict) and part in current:
  65. current = current[part]
  66. else:
  67. return None
  68. return current
  69. async def _send_request(
  70. self,
  71. url: str,
  72. method: str = "POST",
  73. headers: dict[str, str] | None = None,
  74. body: str | None = None,
  75. ) -> httpx.Response | None:
  76. """Send an HTTP request and return the response."""
  77. if not self._validate_url(url):
  78. logger.warning("Blocked REST request to invalid URL: %s", url)
  79. return None
  80. try:
  81. async with httpx.AsyncClient(timeout=self.timeout) as client:
  82. kwargs: dict[str, Any] = {"headers": headers or {}}
  83. if body is not None:
  84. # Try to detect if body is JSON
  85. try:
  86. json.loads(body)
  87. kwargs["content"] = body
  88. if "Content-Type" not in (headers or {}):
  89. kwargs["headers"]["Content-Type"] = "application/json"
  90. except (json.JSONDecodeError, TypeError):
  91. kwargs["content"] = body
  92. response = await client.request(method.upper(), url, **kwargs)
  93. response.raise_for_status()
  94. return response
  95. except httpx.TimeoutException:
  96. logger.warning("REST smart plug at %s timed out", url)
  97. return None
  98. except httpx.HTTPStatusError as e:
  99. logger.warning("REST smart plug at %s returned error: %s", url, e)
  100. return None
  101. except httpx.RequestError as e:
  102. logger.warning("Failed to connect to REST smart plug at %s: %s", url, e)
  103. return None
  104. except Exception as e:
  105. logger.error("Unexpected error communicating with REST smart plug at %s: %s", url, e)
  106. return None
  107. async def turn_on(self, plug: "SmartPlug") -> bool:
  108. """Turn on the plug. Returns True if successful."""
  109. if not plug.rest_on_url:
  110. logger.warning("No ON URL configured for REST plug '%s'", plug.name)
  111. return False
  112. headers = self._parse_headers(plug.rest_headers)
  113. method = plug.rest_method or "POST"
  114. response = await self._send_request(plug.rest_on_url, method, headers, plug.rest_on_body)
  115. if response is not None:
  116. logger.info("Turned ON REST smart plug '%s' via %s %s", plug.name, method, plug.rest_on_url)
  117. return True
  118. logger.warning("Failed to turn ON REST smart plug '%s'", plug.name)
  119. return False
  120. async def turn_off(self, plug: "SmartPlug") -> bool:
  121. """Turn off the plug. Returns True if successful."""
  122. if not plug.rest_off_url:
  123. logger.warning("No OFF URL configured for REST plug '%s'", plug.name)
  124. return False
  125. headers = self._parse_headers(plug.rest_headers)
  126. method = plug.rest_method or "POST"
  127. response = await self._send_request(plug.rest_off_url, method, headers, plug.rest_off_body)
  128. if response is not None:
  129. logger.info("Turned OFF REST smart plug '%s' via %s %s", plug.name, method, plug.rest_off_url)
  130. return True
  131. logger.warning("Failed to turn OFF REST smart plug '%s'", plug.name)
  132. return False
  133. async def toggle(self, plug: "SmartPlug") -> bool:
  134. """Toggle the plug state by checking status first."""
  135. status = await self.get_status(plug)
  136. if status["state"] == "ON":
  137. return await self.turn_off(plug)
  138. else:
  139. return await self.turn_on(plug)
  140. async def get_status(self, plug: "SmartPlug") -> dict:
  141. """Get current power state.
  142. Returns dict with:
  143. - state: "ON" or "OFF" or None if unreachable
  144. - reachable: bool
  145. - device_name: None (REST plugs don't report device names)
  146. """
  147. if not plug.rest_status_url:
  148. return {"state": None, "reachable": True, "device_name": None}
  149. headers = self._parse_headers(plug.rest_headers)
  150. response = await self._send_request(plug.rest_status_url, "GET", headers)
  151. if response is None:
  152. return {"state": None, "reachable": False, "device_name": None}
  153. # Try to extract state from response
  154. state = None
  155. try:
  156. data = response.json()
  157. if plug.rest_status_path:
  158. raw_value = self._extract_json_path(data, plug.rest_status_path)
  159. if raw_value is not None:
  160. on_value = (plug.rest_status_on_value or "ON").upper()
  161. state = "ON" if str(raw_value).upper() == on_value else "OFF"
  162. else:
  163. # No path configured — try common patterns
  164. raw_value = str(data).upper() if not isinstance(data, dict) else None
  165. if raw_value in ("ON", "TRUE", "1"):
  166. state = "ON"
  167. elif raw_value in ("OFF", "FALSE", "0"):
  168. state = "OFF"
  169. except Exception:
  170. # Response is not JSON — try raw text
  171. text = response.text.strip().upper()
  172. on_value = (plug.rest_status_on_value or "ON").upper()
  173. state = "ON" if text == on_value else "OFF"
  174. return {"state": state, "reachable": True, "device_name": None}
  175. async def get_energy(self, plug: "SmartPlug") -> dict | None:
  176. """Get energy monitoring data.
  177. Each value can come from its own URL or fall back to the shared status URL.
  178. Multipliers convert units (e.g. Wh → kWh with multiplier 0.001).
  179. Two distinct energy counters, because devices differ in which they have
  180. (#2539):
  181. - ``rest_energy_path`` — energy used **today**, resetting at midnight.
  182. - ``rest_energy_total_path`` — a **lifetime** counter that never resets.
  183. A Shelly exposes only this one (``aenergy.total``, in Wh). Reading it as
  184. "today" is wrong all day long, and leaves Total and the hourly snapshots
  185. — which the Statistics page's date filters run on — permanently empty.
  186. Yesterday is not read from the device: no REST device we know of reports
  187. it. It is derived from the lifetime counter's snapshots instead, in
  188. ``services.plug_energy_history``.
  189. Returns dict with energy data or None if not available.
  190. """
  191. if not plug.rest_power_path and not plug.rest_energy_path and not plug.rest_energy_total_path:
  192. return None
  193. headers = self._parse_headers(plug.rest_headers)
  194. energy: dict[str, float | None] = {}
  195. power_url = plug.rest_power_url or plug.rest_status_url if plug.rest_power_path else None
  196. energy_url = plug.rest_energy_url or plug.rest_status_url if plug.rest_energy_path else None
  197. # The lifetime counter almost always rides on the same response as the
  198. # today counter (one Shelly RPC call returns both `apower` and
  199. # `aenergy.total`), so it shares the energy URL and the dedupe below
  200. # collapses them into a single fetch.
  201. total_url = plug.rest_energy_url or plug.rest_status_url if plug.rest_energy_total_path else None
  202. # Fetch data — deduplicate when several resolve to the same URL
  203. fetched: dict[str, Any] = {}
  204. for url in {power_url, energy_url, total_url} - {None}:
  205. fetched[url] = await self._fetch_json(url, headers)
  206. def _read(path: str | None, url: str | None, multiplier: float | None) -> float | None:
  207. if not path or not url or fetched.get(url) is None:
  208. return None
  209. raw = self._extract_json_path(fetched[url], path)
  210. if raw is None:
  211. return None
  212. try:
  213. return float(raw) * (multiplier or 1.0)
  214. except (ValueError, TypeError):
  215. return None
  216. power = _read(plug.rest_power_path, power_url, plug.rest_power_multiplier)
  217. if power is not None:
  218. energy["power"] = power
  219. today = _read(plug.rest_energy_path, energy_url, plug.rest_energy_multiplier)
  220. if today is not None:
  221. energy["today"] = today
  222. total = _read(plug.rest_energy_total_path, total_url, plug.rest_energy_total_multiplier)
  223. if total is not None:
  224. energy["total"] = total
  225. return energy if energy else None
  226. async def _fetch_json(self, url: str, headers: dict[str, str]) -> Any:
  227. """Fetch a URL and parse JSON response. Returns parsed data or None."""
  228. response = await self._send_request(url, "GET", headers)
  229. if response is None:
  230. return None
  231. try:
  232. return response.json()
  233. except Exception:
  234. return None
  235. async def test_connection(self, url: str, method: str = "GET", headers: str | None = None) -> dict:
  236. """Test connection to a REST endpoint.
  237. Returns dict with:
  238. - success: bool
  239. - error: error message if failed
  240. """
  241. url_error = self._url_error(url)
  242. if url_error:
  243. return {"success": False, "error": url_error}
  244. parsed_headers = self._parse_headers(headers)
  245. try:
  246. async with httpx.AsyncClient(timeout=self.timeout) as client:
  247. response = await client.request(method.upper(), url, headers=parsed_headers)
  248. response.raise_for_status()
  249. return {"success": True, "error": None}
  250. except httpx.TimeoutException:
  251. return {"success": False, "error": "Connection timed out"}
  252. except httpx.HTTPStatusError as e:
  253. return {"success": False, "error": f"HTTP {e.response.status_code}: {e.response.reason_phrase}"}
  254. except httpx.RequestError as e:
  255. return {"success": False, "error": f"Connection failed: {e}"}
  256. except Exception as e:
  257. return {"success": False, "error": str(e)}
  258. # Singleton instance
  259. rest_smart_plug_service = RESTSmartPlugService()