rest_smart_plug.py 14 KB

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