rest_smart_plug.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  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.
  162. Each value (power, energy) can come from its own URL or fall back to the shared status URL.
  163. Multipliers are applied to convert units (e.g., Wh → kWh with multiplier 0.001).
  164. Returns dict with energy data or None if not available.
  165. """
  166. if not plug.rest_power_path and not plug.rest_energy_path:
  167. return None
  168. headers = self._parse_headers(plug.rest_headers)
  169. energy: dict[str, float | None] = {}
  170. power_url = plug.rest_power_url or plug.rest_status_url if plug.rest_power_path else None
  171. energy_url = plug.rest_energy_url or plug.rest_status_url if plug.rest_energy_path else None
  172. # Fetch data — deduplicate when both resolve to the same URL
  173. fetched: dict[str, Any] = {}
  174. for url in {power_url, energy_url} - {None}:
  175. fetched[url] = await self._fetch_json(url, headers)
  176. # Extract power value
  177. if plug.rest_power_path and power_url and fetched.get(power_url) is not None:
  178. raw = self._extract_json_path(fetched[power_url], plug.rest_power_path)
  179. if raw is not None:
  180. try:
  181. energy["power"] = float(raw) * (plug.rest_power_multiplier or 1.0)
  182. except (ValueError, TypeError):
  183. pass
  184. # Extract energy value
  185. if plug.rest_energy_path and energy_url and fetched.get(energy_url) is not None:
  186. raw = self._extract_json_path(fetched[energy_url], plug.rest_energy_path)
  187. if raw is not None:
  188. try:
  189. energy["today"] = float(raw) * (plug.rest_energy_multiplier or 1.0)
  190. except (ValueError, TypeError):
  191. pass
  192. return energy if energy else None
  193. async def _fetch_json(self, url: str, headers: dict[str, str]) -> Any:
  194. """Fetch a URL and parse JSON response. Returns parsed data or None."""
  195. response = await self._send_request(url, "GET", headers)
  196. if response is None:
  197. return None
  198. try:
  199. return response.json()
  200. except Exception:
  201. return None
  202. async def test_connection(self, url: str, method: str = "GET", headers: str | None = None) -> dict:
  203. """Test connection to a REST endpoint.
  204. Returns dict with:
  205. - success: bool
  206. - error: error message if failed
  207. """
  208. if not self._validate_url(url):
  209. return {"success": False, "error": "Invalid URL (loopback/link-local addresses are blocked)"}
  210. parsed_headers = self._parse_headers(headers)
  211. try:
  212. async with httpx.AsyncClient(timeout=self.timeout) as client:
  213. response = await client.request(method.upper(), url, headers=parsed_headers)
  214. response.raise_for_status()
  215. return {"success": True, "error": None}
  216. except httpx.TimeoutException:
  217. return {"success": False, "error": "Connection timed out"}
  218. except httpx.HTTPStatusError as e:
  219. return {"success": False, "error": f"HTTP {e.response.status_code}: {e.response.reason_phrase}"}
  220. except httpx.RequestError as e:
  221. return {"success": False, "error": f"Connection failed: {e}"}
  222. except Exception as e:
  223. return {"success": False, "error": str(e)}
  224. # Singleton instance
  225. rest_smart_plug_service = RESTSmartPlugService()