Files
bambuddy/backend/app/services/rest_smart_plug.py
T

261 lines
9.6 KiB
Python

"""Service for controlling smart plugs via generic REST/HTTP API."""
import ipaddress
import json
import logging
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
import httpx
if TYPE_CHECKING:
from backend.app.models.smart_plug import SmartPlug
logger = logging.getLogger(__name__)
class RESTSmartPlugService:
"""Service for controlling smart plugs via generic REST/HTTP API.
Supports any home automation platform with an HTTP API (openHAB, ioBroker, FHEM, Node-RED, etc.).
"""
def __init__(self, timeout: float = 10.0):
self.timeout = timeout
@staticmethod
def _validate_url(url: str) -> bool:
"""Block cloud metadata and link-local IPs."""
try:
parsed = urlparse(url)
hostname = parsed.hostname
if not hostname:
return False
addr = ipaddress.ip_address(hostname)
return not addr.is_loopback and not addr.is_link_local
except ValueError:
# Hostname is not an IP (e.g., "openhab.local") — allow it
return True
def _parse_headers(self, headers_json: str | None) -> dict[str, str]:
"""Parse JSON string to dict of headers."""
if not headers_json:
return {}
try:
headers = json.loads(headers_json)
if isinstance(headers, dict):
return {str(k): str(v) for k, v in headers.items()}
except (json.JSONDecodeError, TypeError):
logger.warning("Failed to parse REST headers JSON: %s", headers_json)
return {}
@staticmethod
def _extract_json_path(data: Any, path: str) -> Any:
"""Extract value using dot notation (e.g., 'state' or 'data.power.status')."""
if not path:
return None
parts = path.split(".")
current = data
for part in parts:
if isinstance(current, dict) and part in current:
current = current[part]
else:
return None
return current
async def _send_request(
self,
url: str,
method: str = "POST",
headers: dict[str, str] | None = None,
body: str | None = None,
) -> httpx.Response | None:
"""Send an HTTP request and return the response."""
if not self._validate_url(url):
logger.warning("Blocked REST request to invalid URL: %s", url)
return None
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
kwargs: dict[str, Any] = {"headers": headers or {}}
if body is not None:
# Try to detect if body is JSON
try:
json.loads(body)
kwargs["content"] = body
if "Content-Type" not in (headers or {}):
kwargs["headers"]["Content-Type"] = "application/json"
except (json.JSONDecodeError, TypeError):
kwargs["content"] = body
response = await client.request(method.upper(), url, **kwargs)
response.raise_for_status()
return response
except httpx.TimeoutException:
logger.warning("REST smart plug at %s timed out", url)
return None
except httpx.HTTPStatusError as e:
logger.warning("REST smart plug at %s returned error: %s", url, e)
return None
except httpx.RequestError as e:
logger.warning("Failed to connect to REST smart plug at %s: %s", url, e)
return None
except Exception as e:
logger.error("Unexpected error communicating with REST smart plug at %s: %s", url, e)
return None
async def turn_on(self, plug: "SmartPlug") -> bool:
"""Turn on the plug. Returns True if successful."""
if not plug.rest_on_url:
logger.warning("No ON URL configured for REST plug '%s'", plug.name)
return False
headers = self._parse_headers(plug.rest_headers)
method = plug.rest_method or "POST"
response = await self._send_request(plug.rest_on_url, method, headers, plug.rest_on_body)
if response is not None:
logger.info("Turned ON REST smart plug '%s' via %s %s", plug.name, method, plug.rest_on_url)
return True
logger.warning("Failed to turn ON REST smart plug '%s'", plug.name)
return False
async def turn_off(self, plug: "SmartPlug") -> bool:
"""Turn off the plug. Returns True if successful."""
if not plug.rest_off_url:
logger.warning("No OFF URL configured for REST plug '%s'", plug.name)
return False
headers = self._parse_headers(plug.rest_headers)
method = plug.rest_method or "POST"
response = await self._send_request(plug.rest_off_url, method, headers, plug.rest_off_body)
if response is not None:
logger.info("Turned OFF REST smart plug '%s' via %s %s", plug.name, method, plug.rest_off_url)
return True
logger.warning("Failed to turn OFF REST smart plug '%s'", plug.name)
return False
async def toggle(self, plug: "SmartPlug") -> bool:
"""Toggle the plug state by checking status first."""
status = await self.get_status(plug)
if status["state"] == "ON":
return await self.turn_off(plug)
else:
return await self.turn_on(plug)
async def get_status(self, plug: "SmartPlug") -> dict:
"""Get current power state.
Returns dict with:
- state: "ON" or "OFF" or None if unreachable
- reachable: bool
- device_name: None (REST plugs don't report device names)
"""
if not plug.rest_status_url:
return {"state": None, "reachable": True, "device_name": None}
headers = self._parse_headers(plug.rest_headers)
response = await self._send_request(plug.rest_status_url, "GET", headers)
if response is None:
return {"state": None, "reachable": False, "device_name": None}
# Try to extract state from response
state = None
try:
data = response.json()
if plug.rest_status_path:
raw_value = self._extract_json_path(data, plug.rest_status_path)
if raw_value is not None:
on_value = (plug.rest_status_on_value or "ON").upper()
state = "ON" if str(raw_value).upper() == on_value else "OFF"
else:
# No path configured — try common patterns
raw_value = str(data).upper() if not isinstance(data, dict) else None
if raw_value in ("ON", "TRUE", "1"):
state = "ON"
elif raw_value in ("OFF", "FALSE", "0"):
state = "OFF"
except Exception:
# Response is not JSON — try raw text
text = response.text.strip().upper()
on_value = (plug.rest_status_on_value or "ON").upper()
state = "ON" if text == on_value else "OFF"
return {"state": state, "reachable": True, "device_name": None}
async def get_energy(self, plug: "SmartPlug") -> dict | None:
"""Get energy monitoring data from the status endpoint.
Returns dict with energy data or None if not available.
"""
if not plug.rest_status_url or (not plug.rest_power_path and not plug.rest_energy_path):
return None
headers = self._parse_headers(plug.rest_headers)
response = await self._send_request(plug.rest_status_url, "GET", headers)
if response is None:
return None
try:
data = response.json()
except Exception:
return None
energy: dict[str, float | None] = {}
if plug.rest_power_path:
raw = self._extract_json_path(data, plug.rest_power_path)
if raw is not None:
try:
energy["power"] = float(raw)
except (ValueError, TypeError):
pass
if plug.rest_energy_path:
raw = self._extract_json_path(data, plug.rest_energy_path)
if raw is not None:
try:
energy["today"] = float(raw)
except (ValueError, TypeError):
pass
return energy if energy else None
async def test_connection(self, url: str, method: str = "GET", headers: str | None = None) -> dict:
"""Test connection to a REST endpoint.
Returns dict with:
- success: bool
- error: error message if failed
"""
if not self._validate_url(url):
return {"success": False, "error": "Invalid URL (loopback/link-local addresses are blocked)"}
parsed_headers = self._parse_headers(headers)
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.request(method.upper(), url, headers=parsed_headers)
response.raise_for_status()
return {"success": True, "error": None}
except httpx.TimeoutException:
return {"success": False, "error": "Connection timed out"}
except httpx.HTTPStatusError as e:
return {"success": False, "error": f"HTTP {e.response.status_code}: {e.response.reason_phrase}"}
except httpx.RequestError as e:
return {"success": False, "error": f"Connection failed: {e}"}
except Exception as e:
return {"success": False, "error": str(e)}
# Singleton instance
rest_smart_plug_service = RESTSmartPlugService()