rest_smart_plug.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. """Service for controlling smart plugs via generic REST/HTTP API."""
  2. import ipaddress
  3. import json
  4. import logging
  5. from typing import TYPE_CHECKING, Any
  6. from urllib.parse import urlparse
  7. import httpx
  8. if TYPE_CHECKING:
  9. from backend.app.models.smart_plug import SmartPlug
  10. logger = logging.getLogger(__name__)
  11. class RESTSmartPlugService:
  12. """Service for controlling smart plugs via generic REST/HTTP API.
  13. Supports any home automation platform with an HTTP API (openHAB, ioBroker, FHEM, Node-RED, etc.).
  14. """
  15. def __init__(self, timeout: float = 10.0):
  16. self.timeout = timeout
  17. @staticmethod
  18. def _validate_url(url: str) -> bool:
  19. """Block cloud metadata and link-local IPs."""
  20. try:
  21. parsed = urlparse(url)
  22. hostname = parsed.hostname
  23. if not hostname:
  24. return False
  25. addr = ipaddress.ip_address(hostname)
  26. return not addr.is_loopback and not addr.is_link_local
  27. except ValueError:
  28. # Hostname is not an IP (e.g., "openhab.local") — allow it
  29. return True
  30. def _parse_headers(self, headers_json: str | None) -> dict[str, str]:
  31. """Parse JSON string to dict of headers."""
  32. if not headers_json:
  33. return {}
  34. try:
  35. headers = json.loads(headers_json)
  36. if isinstance(headers, dict):
  37. return {str(k): str(v) for k, v in headers.items()}
  38. except (json.JSONDecodeError, TypeError):
  39. logger.warning("Failed to parse REST headers JSON: %s", headers_json)
  40. return {}
  41. @staticmethod
  42. def _extract_json_path(data: Any, path: str) -> Any:
  43. """Extract value using dot notation (e.g., 'state' or 'data.power.status')."""
  44. if not path:
  45. return None
  46. parts = path.split(".")
  47. current = data
  48. for part in parts:
  49. if isinstance(current, dict) and part in current:
  50. current = current[part]
  51. else:
  52. return None
  53. return current
  54. async def _send_request(
  55. self,
  56. url: str,
  57. method: str = "POST",
  58. headers: dict[str, str] | None = None,
  59. body: str | None = None,
  60. ) -> httpx.Response | None:
  61. """Send an HTTP request and return the response."""
  62. if not self._validate_url(url):
  63. logger.warning("Blocked REST request to invalid URL: %s", url)
  64. return None
  65. try:
  66. async with httpx.AsyncClient(timeout=self.timeout) as client:
  67. kwargs: dict[str, Any] = {"headers": headers or {}}
  68. if body is not None:
  69. # Try to detect if body is JSON
  70. try:
  71. json.loads(body)
  72. kwargs["content"] = body
  73. if "Content-Type" not in (headers or {}):
  74. kwargs["headers"]["Content-Type"] = "application/json"
  75. except (json.JSONDecodeError, TypeError):
  76. kwargs["content"] = body
  77. response = await client.request(method.upper(), url, **kwargs)
  78. response.raise_for_status()
  79. return response
  80. except httpx.TimeoutException:
  81. logger.warning("REST smart plug at %s timed out", url)
  82. return None
  83. except httpx.HTTPStatusError as e:
  84. logger.warning("REST smart plug at %s returned error: %s", url, e)
  85. return None
  86. except httpx.RequestError as e:
  87. logger.warning("Failed to connect to REST smart plug at %s: %s", url, e)
  88. return None
  89. except Exception as e:
  90. logger.error("Unexpected error communicating with REST smart plug at %s: %s", url, e)
  91. return None
  92. async def turn_on(self, plug: "SmartPlug") -> bool:
  93. """Turn on the plug. Returns True if successful."""
  94. if not plug.rest_on_url:
  95. logger.warning("No ON URL configured for REST plug '%s'", plug.name)
  96. return False
  97. headers = self._parse_headers(plug.rest_headers)
  98. method = plug.rest_method or "POST"
  99. response = await self._send_request(plug.rest_on_url, method, headers, plug.rest_on_body)
  100. if response is not None:
  101. logger.info("Turned ON REST smart plug '%s' via %s %s", plug.name, method, plug.rest_on_url)
  102. return True
  103. logger.warning("Failed to turn ON REST smart plug '%s'", plug.name)
  104. return False
  105. async def turn_off(self, plug: "SmartPlug") -> bool:
  106. """Turn off the plug. Returns True if successful."""
  107. if not plug.rest_off_url:
  108. logger.warning("No OFF URL configured for REST plug '%s'", plug.name)
  109. return False
  110. headers = self._parse_headers(plug.rest_headers)
  111. method = plug.rest_method or "POST"
  112. response = await self._send_request(plug.rest_off_url, method, headers, plug.rest_off_body)
  113. if response is not None:
  114. logger.info("Turned OFF REST smart plug '%s' via %s %s", plug.name, method, plug.rest_off_url)
  115. return True
  116. logger.warning("Failed to turn OFF REST smart plug '%s'", plug.name)
  117. return False
  118. async def toggle(self, plug: "SmartPlug") -> bool:
  119. """Toggle the plug state by checking status first."""
  120. status = await self.get_status(plug)
  121. if status["state"] == "ON":
  122. return await self.turn_off(plug)
  123. else:
  124. return await self.turn_on(plug)
  125. async def get_status(self, plug: "SmartPlug") -> dict:
  126. """Get current power state.
  127. Returns dict with:
  128. - state: "ON" or "OFF" or None if unreachable
  129. - reachable: bool
  130. - device_name: None (REST plugs don't report device names)
  131. """
  132. if not plug.rest_status_url:
  133. return {"state": None, "reachable": True, "device_name": None}
  134. headers = self._parse_headers(plug.rest_headers)
  135. response = await self._send_request(plug.rest_status_url, "GET", headers)
  136. if response is None:
  137. return {"state": None, "reachable": False, "device_name": None}
  138. # Try to extract state from response
  139. state = None
  140. try:
  141. data = response.json()
  142. if plug.rest_status_path:
  143. raw_value = self._extract_json_path(data, plug.rest_status_path)
  144. if raw_value is not None:
  145. on_value = (plug.rest_status_on_value or "ON").upper()
  146. state = "ON" if str(raw_value).upper() == on_value else "OFF"
  147. else:
  148. # No path configured — try common patterns
  149. raw_value = str(data).upper() if not isinstance(data, dict) else None
  150. if raw_value in ("ON", "TRUE", "1"):
  151. state = "ON"
  152. elif raw_value in ("OFF", "FALSE", "0"):
  153. state = "OFF"
  154. except Exception:
  155. # Response is not JSON — try raw text
  156. text = response.text.strip().upper()
  157. on_value = (plug.rest_status_on_value or "ON").upper()
  158. state = "ON" if text == on_value else "OFF"
  159. return {"state": state, "reachable": True, "device_name": None}
  160. async def get_energy(self, plug: "SmartPlug") -> dict | None:
  161. """Get energy monitoring data from the status endpoint.
  162. Returns dict with energy data or None if not available.
  163. """
  164. if not plug.rest_status_url or (not plug.rest_power_path and not plug.rest_energy_path):
  165. return 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 None
  170. try:
  171. data = response.json()
  172. except Exception:
  173. return None
  174. energy: dict[str, float | None] = {}
  175. if plug.rest_power_path:
  176. raw = self._extract_json_path(data, plug.rest_power_path)
  177. if raw is not None:
  178. try:
  179. energy["power"] = float(raw)
  180. except (ValueError, TypeError):
  181. pass
  182. if plug.rest_energy_path:
  183. raw = self._extract_json_path(data, plug.rest_energy_path)
  184. if raw is not None:
  185. try:
  186. energy["today"] = float(raw)
  187. except (ValueError, TypeError):
  188. pass
  189. return energy if energy else None
  190. async def test_connection(self, url: str, method: str = "GET", headers: str | None = None) -> dict:
  191. """Test connection to a REST endpoint.
  192. Returns dict with:
  193. - success: bool
  194. - error: error message if failed
  195. """
  196. if not self._validate_url(url):
  197. return {"success": False, "error": "Invalid URL (loopback/link-local addresses are blocked)"}
  198. parsed_headers = self._parse_headers(headers)
  199. try:
  200. async with httpx.AsyncClient(timeout=self.timeout) as client:
  201. response = await client.request(method.upper(), url, headers=parsed_headers)
  202. response.raise_for_status()
  203. return {"success": True, "error": None}
  204. except httpx.TimeoutException:
  205. return {"success": False, "error": "Connection timed out"}
  206. except httpx.HTTPStatusError as e:
  207. return {"success": False, "error": f"HTTP {e.response.status_code}: {e.response.reason_phrase}"}
  208. except httpx.RequestError as e:
  209. return {"success": False, "error": f"Connection failed: {e}"}
  210. except Exception as e:
  211. return {"success": False, "error": str(e)}
  212. # Singleton instance
  213. rest_smart_plug_service = RESTSmartPlugService()